20 KiB
Архитектура
Архитектурные решения проекта. Если код или новая задача им противоречит, сначала обсуждается и правится этот документ.
1. Общая схема
┌─ location /pb/ ─(префикс /pb срезается)─► PocketBase :8090
Браузер ──► nginx ──┤ ▲
└─ location / ──► Node SSR-сервер :4000 ────────┘
(src/server.ts) PB_URL — напрямую, без nginx
-
Браузер ходит в PocketBase по пути
/pb/*своего origin: SDK создаётся с адресом<origin>/pb, запрос/pb/api/collections/...nginx отправляет в PocketBase как/api/collections/.... -
Node SSR-сервер при рендере обращается к PocketBase напрямую по внутреннему адресу
PB_URL. -
В разработке nginx нет: его роль для
/pb/*выполняет прокси dev-сервера Angular (proxy.conf.mjs). -
PocketBase — единственный бэкенд: база, авторизация, файлы, правила доступа (API rules), кастомные маршруты (
pocketbase/pb_hooks/). Это же и публичный API для будущих клиентов (мобильное приложение, Telegram-бот, десктоп). -
Безопасность данных обеспечивает PocketBase, а не фронтенд. Фронтенд может быть скомпрометирован или заменён другим клиентом, поэтому изоляция пользователей проверяется API-тестами на уровне правил коллекций.
-
SSR-сервер только рендерит страницы и отдаёт статику, API PocketBase он не проксирует. Браузер всегда обращается к своему origin (nginx): нет CORS, cookie общая для приложения.
2. Слои и структура папок
Код организован по фичам (src/app/<feature>/). Внутри фичи — слои в духе DDD:
src/app/orders/
index.ts ← публичный API фичи: что можно импортировать другим фичам
orders.routes.ts ← маршруты фичи (lazy)
provide-orders.ts ← provideOrders(): регистрация реализаций в DI
domain/ ← чистый TypeScript, без Angular и без PocketBase
order.ts ← модель (типы)
order-status.ts ← статусы и подписи
order-filter.ts ← параметры фильтрации/поиска
order-repository.ts ← abstract class OrderRepository (контракт и DI-токен)
data/ ← реализация контрактов через PocketBase
pocketbase-order-repository.ts
order-mapper.ts ← запись PocketBase ⇄ доменная модель
ui/ ← компоненты (одна папка на компонент, если есть html/css)
order-list/order-list.ts
order-form/order-form.ts
testing/ ← фейки для тестов других слоёв и фич
in-memory-order-repository.ts
Правила:
- domain не импортирует ничего из
@angular/*,pocketbase,data/,ui/. Здесь только типы, чистые функции и абстрактные классы. - data знает про PocketBase и домен. Наружу отдаёт только доменные типы: запись PocketBase (
RecordModel) не покидает слойdata. - ui знает только домен и абстрактные классы. Компоненты никогда не импортируют
pocketbaseиdata/. - Фича импортирует другую фичу только через её
index.ts. Глубокие импорты (../clients/data/...) запрещены. - Общая инфраструктура, нужная всем фичам, живёт в
src/app/shared/(напримерshared/pocketbase/, позжеshared/ui/). Туда кладём только то, что реально используется двумя и более фичами.
Фичи MVP
| Папка | Назначение |
|---|---|
shared/pocketbase/ |
URL PocketBase, фабрика клиента, синхронизация cookie, маппинг ошибок |
users/ |
сущность User (профиль владельца аккаунта), без логики входа |
auth/ |
сессия: вход, регистрация, выход, текущий пользователь, guard'ы, формы входа/регистрации |
layout/ |
каркас приложения: шапка, навигация, меню пользователя |
home/ |
главная страница: лендинг для гостя или список заявок для вошедшего |
tags/ |
метки пользователя: диалог управления метками и компонент выбора меток |
clients/ |
клиенты |
orders/ |
заявки, вложения |
share/ |
публичная ссылка и публичная страница заявки |
not-found/ |
страница 404 |
Направление зависимостей между фичами
home ──► orders ──► clients ──► shared
│ │ └───► tags ─────► shared
│ └──────► share ────► shared
└──► auth ──► users
- Зависимости идут только в одну сторону, циклов нет.
- Если «нижней» фиче нужно показать что-то из «верхней» (например, история заявок в карточке клиента), нижняя объявляет точку расширения —
InjectionTokenсо списком компонентов, — а верхняя регистрирует в ней свой компонент вprovideXxx(). Такclientsничего не знает о заявках.
Сущность заявки в коде называется
Order; коллекция, папка и маршруты —orders.
3. Dependency Injection через абстрактные классы
Каждый репозиторий и сервис, у которого может появиться другая реализация, описан абстрактным классом. Абстрактный класс одновременно контракт и DI-токен.
// tags/domain/tag-repository.ts
export abstract class TagRepository {
abstract list(): Promise<Tag[]>;
abstract create(draft: TagDraft): Promise<Tag>;
abstract update(id: string, changes: Partial<TagDraft>): Promise<Tag>;
abstract delete(id: string): Promise<void>;
}
// tags/provide-tags.ts
export function provideTags(): Provider[] {
return [{ provide: TagRepository, useClass: PocketBaseTagRepository }];
}
// app.config.ts — единственное место, где выбираются реализации
providers: [
providePocketBase(),
provideAuth(),
provideTags(),
provideClients(),
provideOrders(),
provideShare(),
];
- Компоненты и сервисы получают зависимость так:
private readonly tags = inject(TagRepository);. - Чтобы поменять реализацию, достаточно изменить
provideXxx()илиapp.config.ts. - В unit-тестах вместо PocketBase подставляется фейк из
testing/:{ provide: TagRepository, useClass: InMemoryTagRepository }. - Методы репозиториев возвращают
Promise(PocketBase SDK промис-ориентирован). В компонентах данные загружаются черезresource(): так SSR дожидается загрузки (resource регистрирует pending task), а состояние доступно как сигналы. - Отдельного слоя сторов нет. Стор (сервис с состоянием на сигналах) заводится только там, где одно состояние реально разделяют несколько компонентов, например список меток для диалога управления, компонента выбора и фильтров.
4. Ошибки
shared/pocketbase/ переводит ClientResponseError в доменные ошибки, которые понимает UI:
| Доменная ошибка | Когда |
|---|---|
NotFoundError |
404 (в том числе «чужая запись»: PocketBase не различает «нет» и «нет доступа») |
ValidationError |
400 с ошибками по полям: fieldErrors: Record<string, string> (коды PocketBase) |
UnauthorizedError |
401/403: сессия недействительна |
NetworkError |
нет связи / PocketBase недоступен |
UI показывает ошибки полей рядом с полями, а общие ошибки — в области уведомлений с role="alert".
5. Аутентификация
- Вход и регистрация по email и паролю через коллекцию
users(PocketBase auth). Регистрация открытая. Подтверждение email и сброс пароля в MVP не делаются. - Токен PocketBase (JWT) хранится в cookie
pb_auth(Path=/,SameSite=Lax,Secureна HTTPS). Cookie нужна, чтобы SSR-сервер знал, кто пришёл. - Cookie не
HttpOnly: браузерному SDK нужен токен для прямых запросов в/pb/api. Риск — кража токена при XSS. Снижаем его так: Angular экранирует шаблоны, нигде не используетсяinnerHTMLс пользовательскими данными, позже добавим CSP. Это осознанный компромисс MVP. - В браузере: при изменении
authStorecookie перезаписывается (exportToCookie); при старте приложения, если токен есть, вызываетсяauthRefresh(); при ошибке — выход. - На сервере: токен читается из заголовка
Cookieтекущего запроса (loadFromCookie). Сервер cookie не пишет и токен не обновляет.
6. SSR и изоляция данных пользователей
Какие маршруты как рендерятся
| Маршрут | Режим | Почему |
|---|---|---|
/ |
Server | лендинг для гостя или список заявок для вошедшего |
/orders/:id |
Server | карточка заявки |
/p/:token |
Server | публичная страница заявки (превью в мессенджерах) |
остальные (/clients/**, формы заявок) |
Client | интерактивные формы, SSR не даёт выгоды |
Настраивается в src/app/app.routes.server.ts.
Правила, без которых данные пользователей могут перемешаться
На сервере каждый HTTP-запрос получает свой экземпляр Angular-приложения и свой root-инжектор. Всё, что живёт в DI, изолировано. Опасно только то, что живёт вне DI — на уровне модуля, общее для всех запросов.
- Нельзя создавать PocketBase на уровне модуля (
export const pb = new PocketBase(...)). Экземпляр создаёт только фабрика вshared/pocketbase/внутри DI. ESLint-правило запрещаетnew PocketBase(во всех остальных файлах. - Нельзя хранить пользовательские данные в переменных модуля, статических полях классов, кэшах вне DI.
- Сервер рендерит страницы с заголовками
Cache-Control: private, no-storeиVary: Cookie: ни браузер, ни прокси не должны отдать одну страницу двум людям. - На сервере используется глобальный
fetchбез общего состояния;authStore— свой у каждого экземпляра PocketBase (неLocalAuthStore, а in-memoryBaseAuthStore). - Защищённые файлы (вложения) не рендерятся на сервере: их URL содержит короткоживущий токен, ему не место в HTML.
- Изоляция проверяется e2e-тестами: параллельные SSR-запросы двух пользователей не видят данных друг друга.
Гидратация
PocketBase SDK использует fetch, а не HttpClient, поэтому встроенный HTTP transfer cache Angular не работает. Чтобы после гидратации не загружать данные второй раз, используется TransferState.
7. URL PocketBase
- Сервер:
PB_URL— переменная окружения Node-сервера, внутренний адрес PocketBase для серверного рендера (по умолчаниюhttp://127.0.0.1:8090). В браузер не передаётся. - Браузер:
<window.location.origin>/pb. Префикс/pb— константаPB_PUBLIC_PATHвshared/pocketbase/; если путь в nginx поменяется, меняется только она. - Всё это задаётся в одном месте:
shared/pocketbase/(InjectionTokenPB_URL, разные провайдеры для сервера и браузера). - Разработка:
ng serveпроксирует/pb/*→PB_URLсо срезанием префикса (proxy.conf.mjs). E2E-тесты production-сборки используют маленький тестовый фронт-прокси, повторяющий правила nginx.
Ожидаемая конфигурация nginx (пример, деплой — вне MVP)
location /pb/ {
proxy_pass http://127.0.0.1:8090/; # слэш в конце срезает префикс /pb
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # для realtime (SSE) PocketBase
proxy_read_timeout 360s;
client_max_body_size 60M; # один файл вложения до 50 МБ + запас
}
location / {
proxy_pass http://127.0.0.1:4000; # Node SSR-сервер
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
- Вложения загружаются по одному файлу на запрос, чтобы укладываться в
client_max_body_size. - В настройках PocketBase нужно указать доверенный заголовок реального IP (
X-Real-IP), иначе лимиты запросов и логи будут видеть IP nginx. - Админку PocketBase удобнее открывать напрямую на порту 8090 (через SSH-туннель), а не через
/pb/_/.
8. Формы и UI
- Формы — Signal Forms (
@angular/forms/signals) со схемами валидации. - Стили — чистый CSS: дизайн-токены в CSS custom properties (
src/styles.css), тёмная тема поprefers-color-scheme, никаких препроцессоров и CSS-фреймворков. - Префикс селекторов компонентов и директив —
ksk(ksk-order-list,kskAutofocus). - Минимальная нагрузка на GPU — приложение должно быть лёгким на слабых телефонах и ноутбуках:
- анимируются только
transformиopacity, коротко (до 200 мс); приprefers-reduced-motion: reduceанимации отключаются; - без
backdrop-filter,filter: blur(), больших и многослойныхbox-shadow, анимированных градиентов,background-attachment: fixed; will-change— только точечно и временно, не «на всякий случай»;- тени — одна мягкая тень из токенов; глубину лучше передавать фоном поверхности и границей;
- длинные списки —
content-visibility: autoсcontain-intrinsic-size, чтобы не рендерить невидимое.
- анимируются только
- Интерфейс только на русском, без i18n-фреймворка. Даты и суммы форматируются в локали
ru-RU, валюта — рубли. - Mobile-first: мастер часто работает с телефона.
- Доступность: AXE без ошибок, WCAG AA.
9. Что сознательно отложено
Подтверждение email и сброс пароля, инвайты, публичный профиль мастера, отчёты и финансы, напоминания, импорт/экспорт, Docker и деплой, мобильное приложение, бот. Архитектура их не блокирует: всё общение с данными идёт через PocketBase API и его правила.