Headless Web Runtime

Цель

Нужно прийти к серверной сборке TMA Platform, которая запускается на Linux в Docker без UI-слоя и отдает приложения web-клиентам через REST/WSS.

Текущий Win32 UI остается рабочим клиентом, но не должен быть центром архитектуры. Серверная логика должна жить отдельно от HWND, Window, DC, GDI и WinAPI message loop.

Целевая схема

platform-core
  доменная логика
  метаданные приложений
  формы и сценарии
  VM/interpreter
  DB/repository layer
        |
        v
platform-ui-runtime
  FormModel
  ControlModel
  EventModel
  PatchModel
        |
        +-- platform-win32
        |     Window, Ctl*, HWND, GDI
        |
        +-- platform-web-server
              boost.beast HTTP/WSS
              auth/session
              JSON protocol

Серверная сборка

Нужен отдельный build target, например TMA_SERVER или HEADLESS.

В этом target запрещены прямые зависимости на:

  • windows.h
  • HWND
  • Window
  • DC
  • Ctl*
  • GDI
  • WinAPI message loop

Сервер должен запускаться примерно так:

tma-server --config /etc/tma/app.yaml

Текущий bootstrap target

Первый переносимый target уже заведен через CMake:

cmake -S . -B /tmp/tma-headless-build -DTMA_BUILD_HEADLESS=ON
cmake --build /tmp/tma-headless-build --target tma-headless
/tmp/tma-headless-build/tma-headless --version

Пока это bootstrap-сборка: она проверяет, что проект может компилироваться на macOS без линковки Win32 UI-слоя, и дает точку входа для постепенного подключения core/server-кода.

Текущий состав target:

  • headless/main.cpp — переносимая точка входа;
  • headless/mysql_smoke.cpp — переносимая smoke-проверка подключения к MySQL/MariaDB через runtime-загрузку client library;
  • version.cpp — общий номер версии;
  • TMA_HEADLESS и NO_GUI — compile definitions для серверной сборки.

Первый Beast backend target:

cmake -S . -B /tmp/tma-web-build -DTMA_BUILD_HEADLESS=ON -DTMA_BUILD_WEB_SERVER=ON
cmake --build /tmp/tma-web-build --target tma-web-server

Отдельная сборка и smoke-test VM:

cmake --build /tmp/tma-web-build --target tma-vm-smoke
/tmp/tma-web-build/tma-vm-smoke

React player:

cd web/player
npm install
npm run build

Запуск единого backend/player:

/tmp/tma-web-build/tma-web-server \
  --host 127.0.0.1 \
  --port 8080 \
  --web-root web/player/dist

После запуска:

  • GET / — React player;
  • GET /api/health — состояние backend и версия платформы;
  • GET /api/apps — список доступных приложений bootstrap-каталога;
  • GET /api/app/{appId}/form/main — JSON-модель формы;
  • POST /api/event — прием UI-события;
  • WS /ws — WebSocket канал событий/patches.

На этом этапе tma-web-server отдает bootstrap UI-модель из кода. Это транспортный вертикальный срез. Следующий слой должен заменить hardcoded-модель на общую UiMenuModel/FormModel, загруженную из XML/БД.

Текущий следующий шаг уже начат: backend подключается к MySQL через headless connector, читает реальный source приложения из __easycontrol:

  • code=1 — XML/source приложения;
  • code=13 — версия приложения.

GET /api/apps возвращает реальные статусы по настроенным базам:

{
  "id": "my1",
  "database": "my1",
  "appVersion": "320",
  "status": "application_source_loaded",
  "sourceBytes": 22723,
  "sourceLoaded": true
}

GET /api/app/{appId}/form/main возвращает состояние server runtime: connector к базе поднят, source приложения загружен, а после первого выполненного обработчика runtime.vm принимает значение legacy_pascal.

Source приложения в рабочих базах хранится как legacy XML wrapper <Encoded Value="...">. Headless backend уже умеет его декодировать:

Encoded XML wrapper
  -> legacy broken-base64 decode
  -> XOR byte stream from 100 upward
  -> zlib uncompress
  -> EasyControlApplication XML

После decode HeadlessVmSession делает lightweight introspection верхнего XML:

  • rootTag;
  • moduleCount;
  • runnableModuleCount;
  • первые модули с Name и Type;
  • отдельные списки mainmenu, form и reference для первичной навигации web player.

Проверенные рабочие базы:

  • old_suvr: sourceKind=encoded, moduleCount=597, runnableModuleCount=381;
  • my1: sourceKind=encoded, moduleCount=16, runnableModuleCount=9;
  • ypc: sourceKind=encoded, moduleCount=1153, runnableModuleCount=684.

GET /api/app/{appId}/form/main уже отдает выбранное боковое меню из реального Module Type="mainmenu":

  • список доступных mainmenu остается в runtime.menus;
  • текущий выбор хранится в runtime.selectedMenuName;
  • form.menu.items содержит пункты выбранного меню из XML Popup/Item;
  • Command, который совпадает с form или reference, превращается в form:* или reference:* item id.

Клик по пункту отправляется в /api/event как ui.event и попадает в HeadlessVmSession. Выбор mainmenu:* переключает текущее меню session и следующая загрузка модели возвращает уже его пункты.

Выбор form:* или reference:* уже загружает XML выбранного модуля и отдает workspace.screen:

  • тип и имя выбранного модуля;
  • размеры формы;
  • title/description;
  • базовые XML-контролы с Left, Top, Width, Height, Name, Caption, DataType;
  • для list/reference — колонки из legacy Config;
  • для справочников — исходный referenceFields в UTF-8 и synthetic grid с колонками.

React player рендерит базовые контролы как реальные DOM-компоненты (input, button, checkbox, контейнеры) в legacy absolute layout. Открытые экраны держат локальные значения контролов, а click/change уходят в /api/event.

Для справочников работает базовый метаданный lifecycle:

  1. Прямое поле типа *table читается из основной таблицы как внешний ключ.
  2. Backend находит у связанной таблицы PrimaryKeyField и DescriptionField и отдает клиенту описание вместо ключа.
  3. Формулы-пути вида Project.GroupProject разрешаются последовательно через XML-метаданные таблиц.
  4. Двойной клик по строке берет первичный ключ, читает атрибут Form справочника и открывает карточку существующей записи.
  5. Именованные контролы карточки заполняются полями родительской таблицы; lookup-контрол получает сырой ключ как value и описание как подпись.
  6. Стандартные именованные фильтры верхней панели хранятся в server session и применяются к SQL до LIMIT 50; lookup передает первичный ключ, период строит диапазон, search ищет по строковым полям таблицы.
  7. XML-свойство Items контрола menu превращается в команды карточки с исходными именами, поэтому SaveBtn маршрутизируется в OnSaveBtnClick.

Проверочный реальный сценарий:

python3 headless/web_reference_smoke.py \
  --base-url http://127.0.0.1:8084 \
  --app suvr_new \
  --limit 5 \
  --exercise-task-card

node headless/web_reference_browser_smoke.mjs \
  http://127.0.0.1:9223 \
  /tmp/tma-reference-card.png

Он открывает taskReference, проверяет, что Project, Employee, Status, Project.GroupProject и Status.StatusGroup не содержат сырые ключи, фильтрует строки по проекту и открывает taskForm выбранной задачи.

Реальный вертикальный срез VM

VM отделена в самостоятельную CMake-библиотеку tma-vm-core. В нее входят существующие compiler, bytecode assembler и interpreter Similar Pascal, но не входят Beast, React, MySQL и Win32 UI.

HTTP/WSS session
  -> HeadlessVmSession
      -> LegacyPascalVm (tma-vm-core)
          -> FormRecord
          -> DatabasePort
              -> MysqlDatabasePort

DatabasePort является границей VM и инфраструктуры. Интерпретатор вызывает Apply, Transaction, Commit и Rollback, не зная о MySQL. MysqlDatabasePort находится вне VM core и реализует SQL для текущего connector.

Первый проверенный сценарий использует реальный модуль CLD_dirYearForm из suvr_new:

  1. React открывает форму из XML.
  2. Save отправляет значения полей и control=ok, event=click одним запросом.
  3. Session вычисляет нативное имя события OnOkClick.
  4. Существующий Similar Pascal compiler компилирует Programm формы.
  5. Interpreter выполняет OnOkClick -> save -> Apply -> Close.
  6. MysqlDatabasePort записывает CLD_dirYear, используя Parent, Fields и PrimaryKeyField из XML.
  7. Web player получает close requested и закрывает вкладку.

Сценарий проверен прямым чтением созданной строки из MySQL. Это не mock и не отдельная реализация Pascal.

Стенд контролов

Для последовательной разработки web-контролов есть отдельная XML-карточка headless/fixtures/control_test_module.xml. Она не записывается в рабочую БД и подключается к runtime только через переменную окружения:

TMA_CONTROL_TEST_FIXTURE=headless/fixtures/control_test_module.xml \
TMA_APP_DBS=suvr_new \
/tmp/tma-web-build/tma-web-server \
  --host 127.0.0.1 \
  --port 8084 \
  --web-root web/player/dist

При включенном fixture runtime создает отдельную таблицу __web_control_test. Кнопка Save выполняет существующий Similar Pascal обработчик Apply, после чего данные перечитываются из MySQL и отображаются во встроенном гриде. Рабочие таблицы приложения не изменяются.

API/VM-проверка выполняется командой:

python3 headless/web_control_smoke.py \
  --base-url http://127.0.0.1:8084 \
  --app suvr_new

Smoke-тест вводит значения карточки, выполняет Save -> Apply, проверяет все сохраненные значения в перечитанной строке грида и затем выполняет Close. После каждого события отдельно проверяется /api/health.

Для проверки настоящего React DOM используется Chrome DevTools:

node headless/web_control_browser_smoke.mjs \
  http://127.0.0.1:9223 \
  /tmp/tma-web-database-card.png

Browser smoke проверяет взаимное исключение radio-вариантов, типизированные поля периода, ввод значений, Save и появление строки в гриде. Третий аргумент необязателен и задает путь для контрольного screenshot.

Текущие ограничения первого среза:

  • существующая запись загружается по первичному ключу при открытии карточки из справочника, но lifecycle нескольких одновременно открытых записей одной формы еще требует отдельного instance id на сервере;
  • в DB-адаптер передаются только именованные контролы, совпадающие с полями XML-таблицы;
  • headless VM поддерживает динамический Record, пользовательские типы Record, их поля, копирование и методы Item, ItemExists, ItemCount, ItemName;
  • в headless VM пока явно запрещены inline SQL и выполнение Dataset;
  • нет полного объекта формы и контролов внутри Pascal, кроме серверных мостов Apply и Close;
  • стандартные фильтры верхней панели применяются на сервере, но произвольный прикладной OnRequery с вызовами модулей/inline SQL и серверная пагинация еще не подключены;
  • lookup-контрол временно получает не более 5000 вариантов; большие справочники должны перейти на серверный поиск;
  • patches свойств контролов и серверная валидация типов полей еще не реализованы.

Следующий этап: вынести lifecycle открытых форм в отдельный runtime service, добавить серверные query/filter/page contracts для справочников и подключать используемые прикладными формами Pascal API по одному, с интеграционными сценариями на реальных модулях.

Нельзя подменять VM браузером таблиц. Web player остается клиентом runtime, а не DB explorer.

В headless target нельзя добавлять файл, если он напрямую включает или требует:

  • windows.h;
  • VinLib/winapi*;
  • HWND, HDC, HBITMAP, HICON;
  • Window, DC, Ctl*;
  • GDI/OLE/MDI/message loop;
  • редактор форм и Win32-контролы.

Если нужный код смешивает бизнес-логику и UI, сначала выносим модель/сервис в отдельный файл без WinAPI, затем подключаем этот файл к tma-headless.

MySQL smoke-check

Для проверки старых рабочих баз из macOS/headless target добавлена команда:

tma-headless --mysql-smoke

Параметры подключения берутся только из переменных окружения:

TMA_DB_HOST=192.168.1.109
TMA_DB_PORT=3306
TMA_DB_USER=root
TMA_DB_PASSWORD=...
TMA_DB_NAME=old_suvr
TMA_DB_CHARSET=cp1251

На macOS достаточно установить client connector:

brew install mariadb-connector-c

tma-headless не линкуется с MySQL SDK на этапе сборки. Он загружает libmariadb/libmysqlclient в runtime через dlopen, поэтому headless target остается сборочным bootstrap без жесткой зависимости на установленный SDK.

Для старых MySQL 5.7 с legacy/self-signed SSL сертификатом smoke-check отключает проверку сертификата через MYSQL_OPT_SSL_VERIFY_SERVER_CERT, но не отключает сам TLS, если сервер его требует.

Если библиотека лежит в нестандартном месте, можно явно указать путь:

TMA_MYSQL_CLIENT_LIB=/path/to/libmariadb.3.dylib tma-headless --mysql-smoke

Текущий результат проверки локальной сети:

  • old_suvr, my1, ypc успешно открываются с macOS через mariadb-connector-c;
  • сервер отвечает 5.7.44-log;
  • smoke-check читает первые таблицы через information_schema.TABLES.

Сервер в Docker должен:

  • загрузить конфигурацию клиента;
  • открыть подключение к БД;
  • загрузить метаданные приложения;
  • поднять HTTP/WSS;
  • отдать список доступных приложений;
  • отдать конкретную форму как JSON;
  • принимать события клиента;
  • выполнять обработчики формы;
  • возвращать patches клиенту.

REST/WSS

REST используется для начальной загрузки и статических ресурсов:

GET /api/apps
GET /api/app/{appId}/form/{formId}
GET /api/assets/image/{imageId}

WSS используется для живого UI:

{
  "type": "ui.event",
  "form": "MainForm",
  "control": "MainMenu",
  "item": "Balance",
  "event": "click"
}

Ответ сервера:

{
  "type": "ui.patch",
  "patches": [
    {
      "control": "MainMenu",
      "item": "Balance",
      "property": "enabled",
      "value": false
    }
  ]
}

UI Model

UI-модель должна быть общей для Win32 и Web.

Пример:

struct UiControl {
    std::string id;
    std::string type;
    bool visible = true;
    bool enabled = true;
};

struct UiMenuItem {
    std::string id;
    std::string caption;
    bool visible = true;
    bool enabled = true;
    bool expanded = false;
    std::vector<UiMenuItem> children;
};

struct UiPatch {
    std::string controlId;
    std::string itemId;
    std::string property;
    JsonValue value;
};

Важно: нельзя строить web API от Win32-контрола.

Неправильно:

CtlSideMenu -> JSON

Правильно:

XML -> UiMenuModel -> Win32 adapter
                 -> JSON adapter

Event Router

Один обработчик событий должен обслуживать Win32 и Web.

Win32 click -> UiEventRouter -> OnBalanceClick
Web click   -> UiEventRouter -> OnBalanceClick

Обработчик формы не должен знать, откуда пришло событие.

Первый переносимый control

Первым переносим menu, потому что он уже близок к headless-модели:

  • XML описывает структуру;
  • item имеет Name, Caption, Visible, Enabled, Command;
  • событие клика мапится на On<Name>Click;
  • состояние можно отдавать как JSON;
  • изменения можно возвращать как patches.

Минимальный milestone:

  1. Вынести чтение XML меню в независимую модель UiMenuModel.
  2. Сделать адаптер UiMenuModel -> PopupItems для Win32.
  3. Сделать сериализацию UiMenuModel -> JSON.
  4. Сделать UiEventRouter для click-событий.
  5. Возвращать patches при изменении Caption, Visible, Enabled.
  6. Отдать web-клиенту форму, содержащую только menu.

Deployment

Текущая целевая модель по клиентам:

  • отдельный контейнер на клиента;
  • отдельный домен на клиента;
  • локальные пользователи клиента;
  • общий SaaS login можно добавить позже как внешний сервис поверх этой схемы.

Пример:

traefik/nginx
    |
    +-- client-a.example.com -> tma-server-client-a
    +-- client-b.example.com -> tma-server-client-b

Правило миграции

Новые переносимые контролы делаются через модель и два адаптера:

ControlModel
  -> Win32 adapter
  -> Web JSON adapter

Запрещено добавлять новую бизнес-логику прямо в Win32-контрол, если эта логика нужна web-клиенту.