Миграция на BFF: серверный bootstrap
При открытии приложения BFF подготавливает пользователя в первом HTML. Клиент строит интерфейс собственным React команды, используя эти данные до первого рендера. Это проект для реализации студентами; сервер не рендерит дерево React. Общая картина — трек BFF, процедуры — «Контракт», правила сессии — «Авторизация и CSRF».
Контракт
В исходном apps/client/index.html ровно один элемент, который Vite сохраняет в сборке:
<script id="bootstrap" type="application/json"></script>BFF вставляет в него BootstrapData = { user: CurrentUser | null }. CurrentUser — тип результата users.me, выведенный из AppRouter, а не отдельная ручная DTO-модель. BFF экспортирует BootstrapData; клиент получает его через import type { BootstrapData } from '@cdd/bff', как AppRouter. В клиентский бандл не входят серверный код, Orval-клиент Go и сериализатор.
| Данные | Где доступны |
|---|---|
user: CurrentUser | DTO пользователя в bootstrap и через users.me |
user: null | Начальное состояние гостя |
| Access/refresh-токены, сроки токенов, зашифрованная сессия, ключи, служебные ответы Go | В JSON не попадают |
| Блокноты, ячейки и состояние рантайма | Клиент загружает через tRPC |
HTML-запрос
Поток: браузер → Caddy → BFF → общий слой сессии → Go GET /users/me → JSON в HTML → браузер. Caddy передаёт HTML и tRPC в BFF вместо чтения корневого index.html из S3; JS/CSS и другие ассеты по адресам CDN браузер получает напрямую из CDN.
- BFF читает и расшифровывает
__Host-Http-session. Нет cookie — HTTP200и{ user: null }, Go для определения пользователя не вызывается. Повреждённая cookie или истёкший refresh — тот же гость с удалением недействительной cookie. - Сессия использует общую с tRPC политику: refresh, если до
accessExpменьше 30 с, один повтор после401, объединение refresh по SHA-256 refresh-токена и кеш результата на 10 секунд. Одновременные HTML- и RPC-запросы участвуют в одном механизме refresh. - Общий Orval-клиент вызывает
GET /users/meс Bearer и, на двух VPS,X-BFF-Key. Запросы Go ограничены конечным таймаутом общего сетевого клиента; отдельной политики повторов у bootstrap нет. - DTO успешного ответа становится
user. Отклонённый refresh или повторный401означают гостя и удаление cookie. Остальные ошибки разбираются отдельно ниже. - Новая либо удаляемая cookie записывается в заголовки этого HTML-ответа до отправки тела. Ответ —
text/html; charset=UTF-8с сериализованным{ user }.
HTML-хендлер не вызывает собственный HTTP endpoint tRPC и не подделывает X-CSRF. Общий слой чтения сессии и refresh выделяется из транспорта: createContext вызывает его после CSRF-проверок RPC, HTML-хендлер — после определения типа запроса. Логика не копируется в два места.
Cookie остаётся HttpOnly, Secure, с Path=/, без Domain, AES-256-GCM и сроком по refresh. SameSite=Lax позволяет передать её на внешний GET верхнего уровня: переход с GitHub или Telegram в обычном браузере с сохранённой сессией сразу получает пользователя. При Strict прямой ввод адреса или закладка тоже отправляют cookie, а первоначальный переход с GitHub даёт гостевой HTML: cookie остаётся в браузере, но стартового users.me у нас нет. Таблица и объяснение — в «Cookie сессии». Встроенный браузер другого приложения может иметь своё хранилище cookie и быть гостем. Внешний GET HTML допустим без X-CSRF и с Sec-Fetch-Site: cross-site: он не меняет предметные данные и не выполняет logout. У /api/trpc сохраняются X-CSRF: 1 на каждом вызове, точный Origin для POST и Sec-Fetch-Site: same-origin для POST, если заголовок есть; разрешающего CORS нет.
Маршруты обрабатываются до SPA fallback в таком порядке:
| Запрос | Обработка |
|---|---|
/api/trpc и /api/trpc/* | Официальный fetch-адаптер tRPC, его CSRF и конверты ошибок |
/api/v1 и остальные внешние /api/* | Ошибка маршрутизации, Go API наружу недоступен |
/health | Внутренний liveness, 200 ok, без вызова Go; Caddy наружу не публикует |
| Публичные файлы клиентской сборки | Отдельный маршрут по явному списку, без bootstrap и вызова Go |
| GET HTML, включая прямую ссылку на экран | SPA fallback с bootstrap для Accept: text/html |
| HEAD HTML | Статус и заголовки соответствующего GET, без тела |
| Остальные методы и запросы не к HTML/публичным файлам/API | Ошибка маршрутизации |
Favicon, robots, manifest и другие файлы основного origin входят в явный список сборки: произвольного доступа к файловой системе образа нет. API и служебные ошибки не превращаются в HTML.
Вставка данных
Проверка шаблона выполняется один раз при старте:
- Прочитать неперсональный шаблон из фиксированного пути образа в память (состав образа); ошибка чтения — отказ старта.
- Посчитать все элементы с
id="bootstrap": нужен ровно один. Ноль или два — отказ старта. - Проверить тег
scriptиtype="application/json"единственного элемента. Другой тег или тип — отказ старта. Проверенный шаблон после старта не меняется.
Для каждого запроса создаётся свой Bun HTMLRewriter, замыкающий только данные этого запроса. Сериализация — серверный serialize-javascript с isJSON: true, без unsafe: true. type="application/json" сам не защищает от </script> внутри строки; сериализатор экранирует HTML-значимые символы. JSON передаётся в setInnerContent с html: true: повторное HTML-экранирование испортит JSON.
// Серверный пример: template уже прочитан и проверен при старте BFF.
import serialize from 'serialize-javascript';
import type { BootstrapData } from './bootstrap-types';
export function renderBootstrap(template: string, data: BootstrapData, headers: Headers): Response {
const json = serialize(data, { isJSON: true });
headers.set('Content-Type', 'text/html; charset=UTF-8');
headers.set('Cache-Control', 'private, no-store');
const response = new Response(template, { headers });
return new HTMLRewriter()
.on('script#bootstrap', {
element(element) {
element.setInnerContent(json, { html: true });
},
})
.transform(response);
}Пример преобразователя, а не готовый HTML-хендлер. headers уже содержит решения общей сессии по Set-Cookie; заголовков длины и кодирования исходного файла в нём нет. Тип BootstrapData определяется в BFF по результату users.me и экспортируется из пакета.
Ручная HTML-склейка, EJS/Nunjucks и исполняемое присваивание в window не нужны. Клиент разбирает JSON как данные. Исходные строки после JSON.parse() должны восстановиться без искажения: <, >, &, кавычки, переводы строк и попытка закрыть script проверяются отдельно.
Старт клиента
До первого рендера клиент:
- Находит единственный script и читает
textContent. - Делает
JSON.parseи проверяет конверт данных. - Инициализирует собственный стор сессии, затем строит интерфейс.
Дублирующего стартового users.me нет; процедура остаётся для явного обновления пользователя. Вход, регистрация и выход обновляют стор ответами RPC.
// Клиентский пример: минимальное начальное состояние своего стора.
import type { BootstrapData } from '@cdd/bff';
function readBootstrap(): BootstrapData {
const slots = document.querySelectorAll('[id="bootstrap"]');
const slot = slots[0];
if (slots.length !== 1 || !(slot instanceof HTMLScriptElement) || slot.type !== 'application/json') {
throw new Error('Ошибка запуска приложения');
}
const data: unknown = JSON.parse(slot.textContent ?? '');
if (typeof data !== 'object' || data === null || Object.keys(data).length !== 1 || !('user' in data) ||
(data.user !== null && (typeof data.user !== 'object' || Array.isArray(data.user)))) {
throw new Error('Ошибка запуска приложения');
}
return data as BootstrapData;
}
const sessionStore: BootstrapData = readBootstrap(); // выполнить до первого рендераПример; подключение состояния к своему стору и показ ошибки — в коде приложения. Здесь проверяется конверт bootstrap; DTO пользователя задаётся результатом users.me, без новой независимой модели.
Отсутствующий, повторный или повреждённый блок, включая невалидный JSON, — ошибка запуска. Приложение показывает её вместо гостевого интерфейса; автоматический users.me не маскирует дефект. Для корректных данных user: null означает гостя; пользователь задаёт начальный интерфейс, а последующие запросы всё равно авторизуются сервером. Runtime-библиотек для этого клиент не добавляет.
Ошибки
| Ситуация | HTML и сессия |
|---|---|
| Нет cookie | Гостевой HTTP 200, user: null, без вызова Go |
Недействительная сессия: повреждённая cookie, истёкший или отклонённый refresh, повторный 401 | Гостевой HTTP 200, user: null и удаление cookie |
Сеть, таймаут, 5xx, 403 s2s_forbidden или другой неожиданный ответ Go | HTML 503 временной недоступности; приложение не запускается, подробности не показываются, cookie не удаляется |
Успешный refresh, затем сбой users/me | 503: новая cookie обязательно приходит даже с 503, до тела: старый refresh уже отозван |
В RPC инфраструктурные сбои остаются BAD_GATEWAY, а не HTML 503 — см. конверты ошибок. Cookie, токены и JSON пользователя не пишутся в логи — правила логирования.
Кеширование
| Объект | Правило |
|---|---|
Все bootstrap-ответы, включая гостевые, и HTML 503 | Cache-Control: private, no-store; CDN, Caddy, браузер и BFF не кешируют их как общий готовый HTML |
| Исходный неперсональный шаблон | Единственный общий HTML между запросами; без данных предыдущего пользователя |
| Персональный ответ | Нет общего ETag и возврата 304; длина и кодирование относятся к итоговому ответу, заголовки исходного файла не копируются |
| Навигационный кеш PWA | Персональный HTML исключается при появлении offline-режима |
| Параллельные пользователи | Каждый получает только свои данные |
Разработка
Браузер открывает приложение через BFF: bootstrap и tRPC остаются same-origin. HTML берётся у Vite dev server, проходит dev-преобразования Vite, затем общий bootstrap-преобразователь; HMR сохраняется. Локальный HTTPS обеспечивает работу __Host-Http-session: защитные атрибуты cookie ради dev не ослабляются. Порты и сертификаты задаёт окружение приложения.
Выпуск и откат
Состав образа, фиксированный путь HTML и независимые сборки клиента/BFF описаны в «Сборке и образе». Работающий образ выбирает HTML локально: при запросе нет чтения S3, CDN или current.json; копирование HTML в корень S3 и promote клиента больше не управляют приложением. Это изменение CI/CD вместе с изменением BFF.
Полный порядок публикации ассетов и выкатки образа одного SHA — в «Пути до production». Выбор прежнего образа и retention ассетов — в «Откате». Условия готовности и остановки одного экземпляра — в «Health check».
Первый переезд со сбросом прежних cookie и будущая канарейка описаны в «Переключении»; обычный выпуск и откат с прежним SESSION_KEY сохраняют действующую сессию.
Проверка
Проект: проверить после внедрения. Проверки ниже относятся к будущему приложению; изменение этой документации не означает, что они выполнены.
- Гость:
200, валидный{ user: null }, нет вызова Go. Вошедший пользователь: DTO в первом HTML, нет стартовогоusers.me; перезагрузка, прямая ссылка и внешний переход сохраняют это поведение. - Access перед истечением и ответ
401: общий refresh и новая cookie до тела. Одновременные HTML/RPC используют одно объединение refresh; повреждённая сессия и отказ refresh удаляют cookie. - Сеть, таймаут,
5xx, S2S-ошибка: HTML503с сохранением сессии. Успешный refresh и сбой следующегоusers/me:503содержит новую cookie. - Строки с
</script>не создают HTML-элементы и восстанавливаются послеJSON.parse(); проверить также<,>,&, кавычки, переводы строк. В клиентском бандле нет серверных зависимостей. - Параллельные HTML разных пользователей содержат только свои данные; гостевые и персональные ответы не кешируются как общий HTML, включая PWA.
- В шаблоне нет/два slot — отказ старта BFF; повреждённый блок на клиенте — ошибка запуска.
- API, public и health не попадают в bootstrap, HEAD не имеет тела; tRPC сохраняет CSRF и конверты.
- Bun workspaces, Turborepo и Vite работают без цикла сборки; ассеты публикуются до выкладки. HTML и BFF относятся к одному SHA, откат возвращает прежний HTML с доступными ассетами.