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

23 KiB

Дизайн: каркас приложения

Контекст

Фреймворк и способ раздачи выбраны разведкой spa-framework-choice и записаны решением ADR-2026-08-11-spa-on-vue: Vue 3 с роутером пятой версии, сборка Vite, статика внутри бинарника, шаг сборки в наборе проверок и в образе. Отвергнутые кандидаты — Svelte и React — названы там же с ценой отказа и здесь не пересматриваются. Правила кода приложения лежат в 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, «Настройки с числовым значением», как и прочие числа проекта.

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: там записано, что 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, «Что не решено».
  • Во что слой Node обходится образу — замеряется этой задачей, число уезжает в решение ADR последствием.