Files
transcriber/openspec/changes/archive/2026-08-15-spa-skeleton/design.md
T
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00

258 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Дизайн: каркас приложения
## Контекст
Фреймворк и способ раздачи выбраны разведкой `spa-framework-choice` и записаны
решением [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md):
Vue 3 с роутером пятой версии, сборка Vite, статика внутри бинарника, шаг сборки
в наборе проверок и в образе. Отвергнутые кандидаты — Svelte и React — названы
там же с ценой отказа и здесь не пересматриваются. Правила кода приложения лежат
в [conventions/web-ui.md](../../../docs/conventions/web-ui.md).
Эта задача решает не «на чём писать», а «чем сервис отдаёт написанное и что он
отвечает на пути, которого не знает».
## Что человек увидит иначе
Сегодня браузер, открывший адрес сервиса, получает отказ: сервис отвечает только
на своих адресах, и ни один из них страницы не отдаёт. После изменения тот же
адрес открывает приложение, а обновление страницы посреди приложения возвращает
тот же экран, а не ошибку. Вошедший видит своё имя, не вошедший — предложение
войти.
## Решения
### Один корневой маршрут вместо готовой раздачи библиотеки
У библиотеки есть готовый обработчик статики с подстановкой `index.html` на
непопавший путь. Он **не берётся**, и причин две.
Первая — устройство роутера. Сборка маршрутов сама вешает на путь `/` отказ
«ничего не совпало», если такого маршрута ещё нет. Образец `/` в стандартном
мультиплексоре покрывает все пути, и второй всепокрывающий образец рядом с ним —
`/{path...}` — либо конфликтует с ним, либо разрешается правилом, которое мы не
задаём. Поэтому маршрут ставится ровно на `/`: тем самым он занимает место
библиотечного отказа, а не спорит с ним.
Вторая — подстановка `index.html` слепа. Она не различает «человек обновил
страницу приложения» и «программа ошиблась адресом под корнем хранилища»: оба
случая непопавшие, и оба получили бы разметку с кодом `200`. Перечень корней
знает только сервис, и проверять его обязан он.
Отвергнуто: `apis.Static(fsys, true)` на `/{path...}` — по обеим причинам выше.
### Корни сервиса перечислены одним местом
Правило неизвестного пути обязано перечислять **все** корни (`archive`, «Адреса
приложения живут своим пространством»), а корней сегодня четыре плюс два
отдельных адреса наблюдения. Перечень заводится единой точкой в пакете
транспорта: разложенный по обработчикам, он теряет корень молча, и потерянный
корень означает разметку вместо отказа — то есть ровно ту поломку, от которой
правило и поставлено.
Что делает обработчик по порядку:
1. путь принадлежит корню сервиса или совпадает с адресом наблюдения — отказ
`404` формой библиотеки, то есть **тем же, чем он отвечает сегодня**. Своей
формы отказа здесь не заводится: под корнем приложения она уже есть и её даёт
собственный перехват `/app/`, а под чужими корнями наша форма была бы второй;
2. метод не `GET` и не `HEAD` — отказ `405`;
3. путь ведёт под каталог ресурсов, а файла там нет — отказ `404`;
4. путь совпал с файлом собранного приложения — файл;
5. прочее — разметка приложения.
**Принадлежность корню — два условия, и оба обязательны:** точное совпадение либо
префикс вместе с косой чертой. По одному префиксу корню `/app` достался бы
посторонний `/apple`; по одному префиксу с косой чертой голый `/api` не достался
бы никому и уехал бы разметкой. Проверено прогоном мультиплексора на раскладке
этого дизайна.
**Шаг 3 закрывает случай, ради которого писано правило кэширования.** Разметка
прежней сборки называет ресурсы прежней сборки; после выкладки их имена другие, и
без этого шага браузер получил бы на несуществующий ресурс `200` с разметкой,
отверг бы её по типу содержимого и показал пустой экран — а в кодах ответов
сервиса не осталось бы ничего.
**Перечень не описывает регистрацию, а порождает её.** Наши маршруты — проба
здоровья, метрики, группа приложения и группа входа — вешаются из перечня;
корни библиотеки (`/api`, `/_`) остаются её литералами и держатся своим тестом.
Иначе перечень был бы вторым описанием таблицы маршрутов, которую сегодня ведут
три места, и разошлись бы они молча: корень, заведённый регистрацией и забытый в
перечне, отдаёт разметку с кодом `200` там, где программа ждёт отказ контракта, а
компилятор не видит ничего. Заодно уходит второе перечисление адресов
наблюдения — сегодня уровень журнала называет пробу здоровья и метрики отдельно.
Цена названа: задача трогает сборку маршрутов сверх первоначальных границ.
Принято решением владельца на чекпоинте 2026-08-15; отвергнуты правило-сканер
(ловит только литералы и добавляет шаг проверок) и перечень описанием
(расхождение обнаружил бы клиент).
**В журнал этот маршрут пишет исход, а не путь.** Путь здесь целиком задаёт
спрашивающий, и до изменения он ловился отказом маршрутизатора, а теперь
становится успешным ответом: дословная строка сделала бы журнал местом, куда
аноним пишет свой текст. Пишется исход — разметка, ресурс, отказ — и длина пути.
### Приложение живёт каталогом `web/`, а вшивается своим пакетом Go
Исходники, зависимости и настройка сборки — `web/`; вшиваемый каталог —
`web/embed`; собранное сборщик кладёт в `web/embed/dist`. Вшивает его файл
`web/embed.go` пакетом `web`. Транспорт получает готовую файловую систему
параметром, а не тянет пакет сам: так тест подставляет свою сборку, не собирая
приложение.
Отвергнуто: держать исходники приложения под `internal/`. Каталог означает
«внутреннее для Go», а приложение к сборке Go отношения не имеет; сборщик
фронтенда пришлось бы учить писать результат в чужое дерево.
### Собранного приложения в git нет, а сборка Go без него не ломается
Собранное не коммитится: это производное, и его версия в git разошлась бы с
исходниками молча. Но вшивание требует, чтобы каталог существовал в момент сборки
Go, иначе `go build ./...` отказывает у всякого, кто приложение не собирал.
Отсюда раскладка в два этажа. В git лежит **пустой каталог с меткой**
`web/embed/.gitkeep`, — а сборщик пишет в `web/embed/dist`, то есть **этажом
ниже метки**. Так очистка выходного каталога, которую сборщик делает перед каждой
сборкой, метки не касается: положенная рядом с собранным, она уехала бы первым же
прогоном, а следом — удалённой в ближайший коммит, и `go build ./...` отказал бы
у всякого, кто клонировал заново.
Обработчик, не нашедший разметки, отвечает `503` и честной страницей «приложение
не собрано» — плюс строка в журнале при подъёме. Молча отдавать `404` он не
вправе: инвариант о неслучившемся молчании относится и к этому случаю.
**Что за сборка вшита, видно снаружи.** При подъёме в журнал уходит отпечаток
вшитой разметки. Иначе бинарник молча несёт вчерашнюю сборку — вне набора
проверок порядок шагов ничем не задан, и `go run .` вшивает то, что лежит с
прошлого раза, — а отличить «не та сборка» от «та» нечем: приложение открывается
и ведёт себя как прежняя версия.
Отвергнуто: **коммитить собранное** — производное в git, расходится с
исходниками. Отвергнуто: **ронять старт** без собранного приложения — сломало бы
`go test ./...` и `go run .` у того, кто правит только Go, а команды эти
объявлены в `CLAUDE.md` работающими.
### Заголовки кэширования назначаются, а не достаются умолчанию
Отдача файла в библиотеке заголовка кэширования не ставит вовсе, а вшитый файл не
несёт и времени правки: браузер остаётся с собственной догадкой о том, сколько
хранить копию. Догадка эта разная у разных браузеров, и проверить её со стороны
сервиса нечем.
Поэтому: **признаком служит каталог, а не вид файла.** Ресурс из каталога,
который наполняет сборщик, отдаётся с долгим сроком и пометкой «неизменяемо» —
имена там строит сборщик и несёт в них отпечаток содержимого, поэтому устареть
такой ответ не может. Всё прочее, включая разметку, — с требованием спрашивать
заново.
Признак назван каталогом потому, что вид файла его не даёт: в сборке лежат и
файлы с постоянными именами — иконка, манифест, а после `installable-pwa` ещё и
service worker, у которого отпечатка не бывает по устройству. Такой файл,
однажды отданный как «неизменяемый», не отзывается со стороны сервиса ничем:
запросов он больше не увидит, а у установленного на телефон приложения залипший
service worker означает, что новая сборка не доходит вовсе.
**Отпечаток в именах здесь не нормируется.** Его делает сборщик, сервис имён не
выбирает и их нарушения не заметит — значит это правило конвенции приложения, а
не спеки: спеки нормируют поведение сервиса, а инструмент, которым его собирают,
не нормируют вовсе (`architecture.md`, преамбула).
Долгий срок — год. Число живёт настройкой в [database.md](../../../docs/database.md),
«Настройки с числовым значением», как и прочие числа проекта.
### Node приходит отдельной ступенью образа
Ступеней в образе становится три: сборка приложения, сборка бинарника, рабочий
слой. Приложение собирается **до** бинарника, потому что вшивание требует
готового каталога. Рабочего слоя это не касается: Node в нём не остаётся.
Цена названа решением ADR и здесь только подтверждается: во что слой обходится
образу по времени и по весу, не замерялось — замер делает эта задача.
### Node не ставится на машину, а зовётся контейнером
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
а не вызывает их из `PATH`. Требованием к машине разработчика становится docker,
которым и так собирается образ, — второго устанавливаемого окружения сверх
`ffmpeg` не появляется вовсе.
Так снимается расхождение, которое иначе завелось бы молча: версия Node на машине
разработчика и версия в образе — два разных числа, и собранное ими приложение
различается ровно тогда, когда различаются они. Обёртка берёт **тот же** образ,
которым собирает ступень `Dockerfile`.
Отсюда три частности, каждая из которых иначе кусается на первом же прогоне:
- **имя образа берётся из `Dockerfile`**, а не объявляется вторым числом в
`Taskfile.yml`: второй дом версии разошёлся бы с первым, и сверять их было бы
нечем — своего шага сверки у него, в отличие от версий Go, нет;
- **контейнер ходит под тем же пользователем, что и вызвавший**, иначе каталог
зависимостей и собранное лягут от `root`, и убрать их с машины обычными
средствами не выйдет;
- **кэш установщика уводится наружу контейнера**, в каталог под `/tmp`: под
чужим пользователем домашнего каталога у контейнера нет, и установка иначе
отказывает, а кэш внутри контейнера не пережил бы прогон — каждый набор
проверок тянул бы зависимости заново, включая задачи, приложения не
касающиеся. В git каталог не попадает по построению: он вне дерева проекта.
**Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
шаг набора проверок правит отслеживаемый файл: обновление зависимости приезжает в
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт совпадать
с собранным в образе. Обе половины молчаливы — набор проверок зелёный в обоих
случаях. Ту же команду зовёт и ступень `Dockerfile`.
**Шаг ходит в сеть, и это второй такой шаг в наборе проверок.** Установщик
обращается к реестру пакетов, а docker — к реестру образов; без сети шаг
краснеет. Отказ сети и реестра — **отказ окружения, код 3**, отдельно от красной
сборки, у которой код 1. Утверждение `CLAUDE.md` «шагу `vulns` нужна сеть, и он
один такой» перестаёт быть верным и правится этой же задачей: иначе следующий
читатель раздела «Гейт» получит неверную посылку.
Решение правит **последствие** [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md):
там записано, что Node и его установщик становятся нужны на машине
разработчика. Нужен docker; сам ADR при этом не пересматривается — выбор
фреймворка и наличие шага сборки остаются прежними.
Отвергнуто: **ставить Node на машину** — вводит второе устанавливаемое окружение
и разъезжается с версией в образе. Отвергнуто: **дать выбор — контейнер или
локальный Node** — это второй способ делать одно и то же, и собранное ими
различалось бы в зависимости от того, у кого что стоит.
### Код приложения проверяется машиной, а не глазами
Проверок у приложения две, и обе входят в набор проверок наравне со сборкой:
**Biome** — форматирование и статический анализ, **юнит-тесты Vue** — поведение
экрана, включая показ вошедшего и три ветки ответа на вопрос «кто вошёл».
Гоняются они тем же контейнером, что и сборка, — второго окружения не заводится.
Без них требование «приложение показывает вошедшего» — единственное, ради чего
задача заводится, — не проверял бы ни один шаг: набор проверок судил бы только
раздачу из Go и оставался зелёным на сломанном приложении. Ручная проверка эту
дыру не закрывает: следующая задача её не повторит.
Оговорка на Biome одна и проверяется первым же прогоном: если он не покрывает
разметку однофайловых компонентов, инструмент меняется. Решением владельца на
чекпоинте 2026-08-15 взят он, а замена — отдельное решение, не подстановка по
ходу.
### Шаг сборки приложения — первый в наборе проверок
Он стоит **перед** сборкой Go по той же причине: вшивается то, что собрано.
Недостающий docker — отказ окружения, код 3 по общему словарю (`CLAUDE.md`,
«Гейт»), наравне с недостающим компилятором C у шага тестов; красная сборка
приложения — код 1, отказ проверки наравне с `go build`.
## Границы
Экранов, кроме показа вошедшего, не делается: их заводят `upload-and-status-screen`
и `records-list-screen`. Установка на телефон, service worker и версионирование
статики — `installable-pwa`. Готовый набор компонентов не берётся: он не выбран,
а способен удвоить собранный файл.
## Открытые вопросы
- **Набор компонентов и стили** — не решено, дом вопроса
[conventions/web-ui.md](../../../docs/conventions/web-ui.md), «Что не решено».
- **Во что слой Node обходится образу** — замеряется этой задачей, число уезжает
в решение ADR последствием.