Динамическая маршрутизация

Маршрутизатор Web Host не настраивается статически. При запуске он получает от бэкенда текущий набор маршрутов монтирования страниц и добавляет их в экземпляр Vue Router. Это означает, что новая запись view.page с заявкой mountRoute вступает в силу без каких-либо изменений в самом бандле Web Host.

Mount route sync

Синхронизация маршрутов монтирования при запуске

Когда приложение Web Host инициализируется, до отрисовки какой-либо навигации оно вызывает:

GET /api/public/pages/routes

Ответ — это конверт { success, count, routes }, где routes — отображение шаблона маршрута монтирования в id страницы (сюда входят скрытые/необъявленные страницы, которые всё же заявляют URL). Для каждой записи хост регистрирует маршрут Vue Router, сопоставляющий объявленный путь с компонентом загрузчика страницы, добавляя его как дочерний к родительскому маршруту 'app'.

// Упрощённо из bootstrap Web Host
const { routes } = await api.get('/api/public/pages/routes')
for (const [mountRoute, pageId] of Object.entries(routes)) {
  router.addRoute('app', {
    path: mountRoute,
    component: MountRoutePage,
    props: () => ({ pageId }),
  })
}

После этого переход на /home/anything заставляет маршрутизатор отрисовать iframe страницы main, а переход на /demo/anything — отрисовать iframe страницы iframe-demo, без какого-либо жёстко зашитого знания об этих путях в бандле хоста.

Заявка на путь через mountRoute

Запись view.page заявляет путь в маршрутизаторе хоста, устанавливая mountRoute в блоке meta своего _index.yaml:

- name: main
  kind: registry.entry
  meta:
    type: view.page
    mountRoute: /home/:part(.*)*
    ...

mountRoute — текущее написание для совместимости с ошибкой регистра на бэкенде. Предполагаемый ключ бэкенда — mount_route; продолжайте писать mountRoute, пока исправление бэкенда не будет выпущено.

mountRoute принимает только формы «поймай всё»: /:part(.*)* (корень) или /<literal-prefix>/:part(.*)*, где префикс — один или несколько литеральных сегментов из строчных букв, цифр и дефисов, заканчивающихся обязательным подстановочным :part(.*)*. Произвольные шаблоны Vue Router — именованные параметры, пользовательские регулярные выражения или другие имена параметров (например, /home/:id, /users/:userId(\d+)) — отклоняются: хост поднимает конфликт маршрута монтирования вида syntax, validate_mount_route_syntax на бэкенде завершается ошибкой, а GET /api/public/pages/routes возвращает HTTP 500 (отображается как фатальная полноэкранная ошибка). Подстановочный сегмент :part(.*)* позволяет дочернему приложению управлять собственными подмаршрутами (например, /home/settings, /home/profile/edit), пока хост владеет префиксом /home.

Две записи не должны заявлять один и тот же маршрут. Если две записи view.page заявляют один и тот же mountRoute, валидатор бэкенда (validate_mount_routes в page_registry.lua) фиксирует конфликт дублирующегося маршрута в том же списке проблем, что и синтаксические ошибки, поэтому GET /api/public/pages/routes возвращает HTTP 500, а Web Host отрисовывает фатальный полноэкранный <wippy-error> — ровно как при некорректном mountRoute. Это не игнорируется молча.

Единственное поведение «побеждает первый» — приоритет во время выполнения Vue Router между корневым «поймай всё» (/:part(.*)*) и более конкретным системным маршрутом (chat, c, web, page, keeper, login, logout) или монтированием с более длинным литеральным префиксом: более конкретный маршрут срабатывает первым. Это приоритет разрешения маршрутов, а не обработка дублирующихся маршрутов.

Цикл синхронизации URL

Как только страница загружена в свой iframe, дочернее приложение навигирует внутри себя собственным маршрутизатором. Эти внутренние переходы должны отражаться в адресной строке хоста, чтобы кнопка «назад» браузера, закладки и копирование URL работали корректно. Это делается через пару PostMessage.

Frontend Registry

Дочернее приложение → хост: CmdRouteChanged

Когда маршрутизатор дочернего приложения фиксирует переход (например, пользователь переходит с /home/settings на /home/profile), дочернее приложение отправляет сообщение в родительское окно:

// В дочернем приложении, при смене внутреннего маршрута.
// Код приложения никогда не должен отправлять эти сообщения напрямую — используйте proxy API:
import { host } from '@wippy-fe/proxy'

host.onRouteChanged('/profile', navId)   // только внутренний маршрут; хост сам добавляет префикс монтирования. navId — необязательное число

Прокси сериализует это во внутренний конверт протокола. Этот протокол не является API приложения: не копируйте его и не вызывайте window.parent.postMessage напрямую.

Обработчик сообщений хоста перехватывает это, вызывает router.push(path) для обновления адресной строки через SPA-переход (добавляя запись в историю браузера) без полной перезагрузки страницы, а затем отправляет обратно:

Хост → дочернее приложение: UrlWasUpdatedInParent

После того как хост обновил адресную строку, прокси генерирует @history для дочернего приложения. @wippy-fe/router потребляет это событие и согласует memory router.

Хост отправляет обратно внутренний маршрут дочернего приложения (подпуть после префикса монтирования), а не полный путь хоста, поэтому круговой обмен симметричен: дочернее приложение отправляет internalRoute: '/profile', хост устанавливает адресную строку в /home/profile и отражает обратно path: '/profile', который memory router дочернего приложения выполняет дословно. Дочернее приложение слушает канал событий @history и трактует это как подтверждение того, что URL хоста теперь согласован с его внутренним состоянием.

Круговой обмен держит адресную строку хоста, маршрутизатор дочернего приложения и запись в истории браузера в синхронном состоянии, при этом хосту не нужно ничего знать о внутренней структуре маршрутизации дочернего приложения.

Когда у страницы установлено preventLinkClicks: true в её proxy-инъекциях (см. view.page), хост перехватывает клики по <a> внутри iframe до того, как их обработает браузер. Каждая перехваченная ссылка передаётся в classifyLink, который решает, как её обработать:

LinkKind Условие Действие
host-nav Верхний сегмент пути совпадает с известным литералом mountRoute, встроенным системным маршрутом (chat, c, web, page, keeper, login, logout) или корневым «поймай всё» preventDefault + host.navigate(normalizedPath)
child-nav Собственный маршрутизатор iframe разрешает путь в реальный (не «поймай всё») маршрут либо никто другой его не заявил RouterLink подприложения решает внутри приложения; хост НЕ вызывает preventDefault и НЕ перезагружает iframe
external Другой origin или схема не http (javascript/mailto/tel/sms/ftp/file/data/blob) Поведение браузера по умолчанию (например, открытие в новой вкладке)
ignore Пустой href или чистый якорь (#…) preventDefault

Классификатор сначала проверяет собственный локальный маршрутизатор iframe, поэтому ссылка, которую дочернее приложение может разрешить само, остаётся внутри приложения.

classifyLink обращается к тому же списку маршрутов, что был получен при запуске. Ссылка на /demo/step-2 классифицируется как host-nav, потому что /demo/:part(.*)* — зарегистрированный маршрут монтирования: хост переходит на страницу iframe-demo вместо полной перезагрузки страницы.

Это означает, что дочернему приложению не нужно знать о других страницах системы. Оно может отрисовывать обычные ссылки <a href="/demo/step-2">, а классификатор ссылок хоста корректно обработает переход.