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.hHWNDWindowDCCtl*- 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содержит пункты выбранного меню из XMLPopup/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— колонки из legacyConfig; - для справочников — исходный
referenceFieldsв UTF-8 и synthetic grid с колонками.
React player рендерит базовые контролы как реальные DOM-компоненты (input, button, checkbox, контейнеры) в legacy absolute layout. Открытые экраны держат локальные значения контролов, а click/change уходят в /api/event.
Для справочников работает базовый метаданный lifecycle:
- Прямое поле типа
*tableчитается из основной таблицы как внешний ключ. - Backend находит у связанной таблицы
PrimaryKeyFieldиDescriptionFieldи отдает клиенту описание вместо ключа. - Формулы-пути вида
Project.GroupProjectразрешаются последовательно через XML-метаданные таблиц. - Двойной клик по строке берет первичный ключ, читает атрибут
Formсправочника и открывает карточку существующей записи. - Именованные контролы карточки заполняются полями родительской таблицы;
lookup-контрол получает сырой ключ как
valueи описание как подпись. - Стандартные именованные фильтры верхней панели хранятся в server session и
применяются к SQL до
LIMIT 50; lookup передает первичный ключ, период строит диапазон,searchищет по строковым полям таблицы. - 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:
- React открывает форму из XML.
- Save отправляет значения полей и
control=ok,event=clickодним запросом. - Session вычисляет нативное имя события
OnOkClick. - Существующий Similar Pascal compiler компилирует
Programmформы. - Interpreter выполняет
OnOkClick -> save -> Apply -> Close. MysqlDatabasePortзаписываетCLD_dirYear, используяParent,FieldsиPrimaryKeyFieldиз XML.- 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:
- Вынести чтение XML меню в независимую модель
UiMenuModel. - Сделать адаптер
UiMenuModel -> PopupItemsдля Win32. - Сделать сериализацию
UiMenuModel -> JSON. - Сделать
UiEventRouterдля click-событий. - Возвращать patches при изменении
Caption,Visible,Enabled. - Отдать 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-клиенту.