приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
# Дизайн: каркас приложения
|
||||
|
||||
## Контекст
|
||||
|
||||
Фреймворк и способ раздачи выбраны разведкой `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 последствием.
|
||||
Reference in New Issue
Block a user