Перейти к содержимому

@aom-js/visualizer

Пакет строит по дереву контроллеров граф API — маршруты, цепочки звеньев и компонентные схемы с их связями — и рендерит его в одну самодостаточную HTML-страницу. Страница отдаётся обычным endpoint-ом, поэтому карта живёт внутри самого модуля API рядом с /openapi.json. Читать до: @aom-js/router (мета-модель, курсоры, сборка маршрутов), @aom-js/schemer (компонентные схемы). Читать после: @aom-js/api (как endpoint отдаёт HTML).

Роутер уже знает всё, что нужно для карты: Router разворачивает дерево бриджей в плоский список AomRoute, у каждого маршрута есть упорядоченная цепочка курсоров, а OpenApi вместе с SchemasCollector сводит компонентные классы в components.schemas с $ref-связями. Пакет ничего из этого не пересчитывает — он собирает данные в один сериализуемый граф (VisualGraph) и рисует его.

Ключевое решение: граф всегда собирается на собственном экземпляре OpenApi. Причина в том, как устроены отложенные декларации. Значения openApi.* (параметры билдеров, ответы, маркеры доступа) материализуются только в конструкторе AomCursor — до сборки маршрутов их в мета-инстансах просто нет (packages/router/src/common/cursor.ts). Обойти дерево через AomApi.instance(...) без построения маршрутов недостаточно: получится структура без деклараций и с пустыми route.markers. При этом сборщик схем у роутера приложения общий на весь модуль, и поддерево из него не выделить. Поэтому buildVisualGraph строит свой Router на свежем OpenApi — это чистая сборка без ввода-вывода и без сервера, а курсоры создаются заново, так что живой роутер приложения не затрагивается.

Отсюда же следует, что карта строится от любого узла дерева, а не только от корня: любой контроллер — валидный аргумент, для него собирается свой Router со своими префиксами.

Зависимости: @aom-js/core (ClassType), @aom-js/router (Router, OpenApi, AomApi, AomRoute) и reflect-metadata. Обратная зависимость одна: @aom-js/api использует пакет в BaseVisualizerController, поэтому в цепочке сборки визуализатор идёт перед api.

Пакет вводит два собственных термина: граф — структура VisualGraph, сериализуемый срез API; звено — элемент цепочки исполнения маршрута (курсор): middleware, nextpoint, маркер, хук инстанса или сам endpoint.

ЭкспортСигнатураНазначение
buildVisualGraph(source: ClassType | Router, options?: BuildGraphOptions) => VisualGraphСобрать граф от контроллера или от корня живого роутера
renderVisualGraph(graph: VisualGraph, options?: RenderOptions) => stringОтрендерить готовый граф в HTML-страницу
collectControllers(target: ClassType) => Map<string, ClassType>Контроллеры, достижимые по бриджам
Visualizerclass Visualizer(source: AomRoute | Router | ClassType, options?)Компонента карты: .graph — данные, .html — страница, .root — класс-корень
типы графаVisualGraph, VisualController, VisualRoute, VisualChainItem, VisualMiddleware, VisualSchema, VisualSchemaProperty, VisualMember, VisualParameter, VisualResponse, VisualRequestBody, VisualBridge, VisualSchemaRef, VisualNodeKindКонтракт данных для собственных потребителей графа
опцииBuildGraphOptions, RenderOptionstitle, info, skipMarkers; sharedThreshold
ФайлОтветственностьКлючевые символы
src/defines/types.tsКонтракт графаVisualGraph и сопутствующие интерфейсы
src/defines/graph.tsСборка графа из мета-модели роутераbuildVisualGraph, collectControllers, nodeKind, openApiPath, describeSchema, declaredMembers
src/defines/render.tsHTML-документrenderVisualGraph, embedJson
src/defines/styles.tsCSS страницы (строка)styles
src/defines/script.tsКлиентский сценарий (строка)script
src/defines/component.tsКомпонента картыVisualizer
  1. new OpenApi(...) + new Router(target, docs). Сокеты роутер в документ не кладёт — buildVisualGraph регистрирует их сам, иначе их схемы не попадут в сборщик.
  2. Обход бриджей от корня даёт дерево контроллеров: префиксы, pre/post-middleware, хуки, маркеры, наследование. Повторный вход в уже раскрытый контроллер обрывается и попадает в warnings — циклический бридж не уводит обход в бесконечность.
  3. Каждый AomRoute даёт VisualRoute: цепочка — из route.cursors (id, роль, точка прикрепления, порядковый номер), а параметры, тело, ответы, теги и защита — из уже собранной операции документа. Операция находится по ключу метод:путь, где путь пересчитан той же подстановкой :param → {name}, что делает OpenApi.registerPath. Схемы маршрута (schemas, ребро «маршрут → схема») — из параметров, тела и ответов; у потока событий data события — строка с JSON конверта, поэтому схемы событий берутся из свойств конверта itemSchema.properties.data.contentSchema.properties (packages/visualizer/src/defines/graph.ts (buildVisualGraph)).
  4. Звенья агрегируются по всем маршрутам: usedBy — список маршрутов, в цепочку которых звено входит. Это и есть метрика «общности», по ней раскрашивается интерфейс.
  5. Схемы разбираются из components.schemas: свойства с типами и признаками, а $ref (в том числе внутри items, allOf, anyOf, oneOf) становится ребром ER — refs в одну сторону, referencedBy в другую.
  6. declaredMembers читает _.origins узла класса — единственный способ перечислить декорированные методы контроллера, не зная их имён. Так в карту попадают и звенья, объявленные, но никуда не подключённые.

Документ самодостаточен: CSS, сценарий и сам граф вшиты внутрь, внешних загрузок нет. Граф лежит в <script type="application/json"> с экранированными угловыми скобками — имя схемы или заголовок не могут закрыть блок.

Раскладка повторяет вложенность. Столбец на полотне — это глубина от корня: слева корневой контроллер, правее те, до кого от него ведёт бридж, и так далее. Одноранговые контроллеры идут в столбце сверху вниз, обход в глубину, поэтому потомки одного родителя оказываются рядом. Методы не вынесены в отдельную колонку — каждый стоит прямо под своим контроллером. Бриджи показаны линиями между столбцами: они и есть каркас картины, поэтому рисуются всегда.

Структуры данных живут внутри методов. Общего списка схем на странице нет: каждая показана там, где используется, отдельным блоком. Шеврон блока раскрывает состав — свойства с типами и признаком обязательности; кнопка «подсветить» находит эту же структуру во всех остальных методах, гасит непричастное и соединяет вхождения пунктиром. Вложенная структура выбирается кликом по строке свойства, которое на неё ссылается. У всех вхождений одной схемы одинаковый data-key — на нём держится подсветка, а уникальный data-occ нужен, чтобы связать их линиями.

В шапке блока две независимые операции, разведённые по элементам: шеврон сворачивает и разворачивает, название выделяет. Клик по шеврону не всплывает, поэтому свернуть блок можно, не сбив текущее выделение, а выделить — не меняя того, что раскрыто. Esc сбрасывает выделение и поиск (из поля поиска первым же нажатием). Поиск фильтрует по пути, классу, звену и имени структуры.

Выделение подсвечивает окружение и гасит остальное: для звена — все методы, где оно встречается; для структуры — её замыкание по связям плюс методы-потребители; для метода — его цепочка и структуры; для контроллера — его методы, звенья и соседи по бриджам. Подсвеченное раскрывается автоматически, чтобы содержимое было видно сразу. Общие звенья окрашены по числу использований и вынесены в боковую панель списком.

Перерисовка идёт на следующем кадре, но с запасным таймером: в скрытой вкладке requestAnimationFrame не вызывается вовсе, и слой связей оставался бы устаревшим до возвращения фокуса.

Пакет не содержит контроллеров и ничего не знает про HTTP — это осознанная граница: визуализатор читает мета-модель, а маршруты объявляет тот слой, которому положено. Есть два способа.

Компонентой — там, где нужно. Создаётся от маршрута, роутера или класса; корнем карты становится корень роутера этого маршрута, то есть модуль, внутри которого компоненту разместили:

@Get("/visualizer")
static Page(@Route() route: AomRoute, @Ctx() ctx: HttpCtx): string {
// строка по умолчанию уходит как text/plain - тип страницы ставим сами
ctx.res.setHeader("Content-Type", "text/html; charset=utf-8");
return new Visualizer(route).html;
}

.html здесь обязателен: строка уходит телом ответа, а объект — в JSON. Заголовок Content-Type нужно выставить самому до return: строковый результат Server по умолчанию отдаёт как text/plain, а не text/html (иначе данные из запроса, попавшие в строку, браузер исполнил бы как разметку), и свой заголовок обработчика при этом не перетирается (packages/api/src/server/http.ts (Server); так же поступает BaseVisualizerController.Page). Это не случайность контракта — у компоненты есть и второй режим: toJSON() возвращает граф, поэтому return new Visualizer(route) из endpoint-а отдаёт данные, а не страницу. Сборка ленивая: пока не спросили graph или html, ничего не считается.

Готовым контроллером из @aom-js/api. BaseVisualizerController объявляет /visualizer и /visualizer.json поверх той же компоненты; подключается бриджем или наследованием, если нужны свои ограничения доступа. Закрывать карту стоит не слабее, чем /openapi.json: страница раскрывает полную структуру API вместе с цепочками доступа.

@Controller()
@Before(AuthBasic.Required)
export class VisualizerController extends BaseVisualizerController {}

Снаружи корень не параметризуется: ни query-параметра, ни отдельного маршрута для поддерева нет. Страница получает граф целиком, а выбор узла — операция клиента через #root=ИмяКонтроллера. Смена корня не порождает обращений к серверу и не перезагружает страницу; ссылка с hash открывает нужный срез сразу.

Пути в срезе остаются абсолютными — это реальные адреса модуля, а не пересборка дерева от нового корня. Сужается только состав: контроллеры, достижимые по бриджам, их маршруты, звенья этих маршрутов и замыкание задействованных схем. Счётчик общности звена пересчитывается внутри среза.

Программно тот же срез недоступен намеренно: buildVisualGraph(SomeController) собирает отдельный граф от этого класса, с собственными префиксами — это другая операция, и её результат не совпадает со срезом.

  • Сборка графа перестраивает дерево маршрутов на каждый запрос. Для модуля из ~75 маршрутов это единицы миллисекунд, кеша нет намеренно: карта должна отражать текущее состояние процесса.
  • Маркеры маршрутов сериализуются через JSON.stringify как есть. Структура с циклом не роняет страницу — маршрут получает пустые маркеры, а причина попадает в warnings.
  • Порядок бриджей в карте обратен порядку объявления: декораторы класса выполняются снизу вверх. Это поведение роутера, не визуализатора.
  • Websocket-маршруты роутер в документ OpenAPI не кладёт; пакет регистрирует их сам, но декларации у сокетов обычно беднее, чем у http-маршрутов.

Из корня ядра: bunx vitest run packages/visualizer.