Перейти к содержимому
Загрузка статьи…

Трек BFF · Модуль №2

Миграция на BFF: серверный bootstrap ​

При открытии приложения BFF подготавливает пользователя в первом HTML. Клиент строит интерфейс собственным React команды, используя эти данные до первого рендера. Это проект для реализации студентами; сервер не рендерит дерево React. Общая картина — трек BFF, процедуры — «Контракт», правила сессии — «Авторизация и CSRF».

Контракт ​

В исходном apps/client/index.html ровно один элемент, который Vite сохраняет в сборке:

html
<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: CurrentUserDTO пользователя в 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.

modules/2/bff-bootstrap
  1. BFF читает и расшифровывает __Host-Http-session. Нет cookie — HTTP 200 и { user: null }, Go для определения пользователя не вызывается. Повреждённая cookie или истёкший refresh — тот же гость с удалением недействительной cookie.
  2. Сессия использует общую с tRPC политику: refresh, если до accessExp меньше 30 с, один повтор после 401, объединение refresh по SHA-256 refresh-токена и кеш результата на 10 секунд. Одновременные HTML- и RPC-запросы участвуют в одном механизме refresh.
  3. Общий Orval-клиент вызывает GET /users/me с Bearer и, на двух VPS, X-BFF-Key. Запросы Go ограничены конечным таймаутом общего сетевого клиента; отдельной политики повторов у bootstrap нет.
  4. DTO успешного ответа становится user. Отклонённый refresh или повторный 401 означают гостя и удаление cookie. Остальные ошибки разбираются отдельно ниже.
  5. Новая либо удаляемая 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.

Вставка данных ​

Проверка шаблона выполняется один раз при старте:

  1. Прочитать неперсональный шаблон из фиксированного пути образа в память (состав образа); ошибка чтения — отказ старта.
  2. Посчитать все элементы с id="bootstrap": нужен ровно один. Ноль или два — отказ старта.
  3. Проверить тег script и type="application/json" единственного элемента. Другой тег или тип — отказ старта. Проверенный шаблон после старта не меняется.

Для каждого запроса создаётся свой Bun HTMLRewriter, замыкающий только данные этого запроса. Сериализация — серверный serialize-javascript с isJSON: true, без unsafe: true. type="application/json" сам не защищает от </script> внутри строки; сериализатор экранирует HTML-значимые символы. JSON передаётся в setInnerContent с html: true: повторное HTML-экранирование испортит JSON.

ts
// Серверный пример: 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 проверяются отдельно.

Старт клиента ​

До первого рендера клиент:

  1. Находит единственный script и читает textContent.
  2. Делает JSON.parse и проверяет конверт данных.
  3. Инициализирует собственный стор сессии, затем строит интерфейс.

Дублирующего стартового users.me нет; процедура остаётся для явного обновления пользователя. Вход, регистрация и выход обновляют стор ответами RPC.

ts
// Клиентский пример: минимальное начальное состояние своего стора.
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 или другой неожиданный ответ GoHTML 503 временной недоступности; приложение не запускается, подробности не показываются, cookie не удаляется
Успешный refresh, затем сбой users/me503: новая cookie обязательно приходит даже с 503, до тела: старый refresh уже отозван

В RPC инфраструктурные сбои остаются BAD_GATEWAY, а не HTML 503 — см. конверты ошибок. Cookie, токены и JSON пользователя не пишутся в логи — правила логирования.

Кеширование ​

ОбъектПравило
Все bootstrap-ответы, включая гостевые, и HTML 503Cache-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-ошибка: HTML 503 с сохранением сессии. Успешный 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 с доступными ассетами.