Skip to main content

@lowcode/runtime-core

@lowcode/runtime-core — это движок выполнения сгенерированных приложений, общий для:

  • превью DSL-приложений в builder-web;
  • будущего отдельного runtime-host SPA;
  • любых сред, где нужно исполнить React-бандл, сгенерированный @lowcode/dsl-compiler.

Runtime-core почти ничего не знает про конкретный UI, но знает всё про:

  • структуру рантайм-состояния (RuntimeSnapshot),
  • типы команд (RuntimeCommand),
  • протокол причин изменений (RuntimeChangeReason),
  • контракт инстанса (RuntimeInstance),
  • контекст компонентов и действий (RuntimeContext),
  • работу с источниками данных (RuntimeDataSourceExecutor + snapshot.dataSources),
  • базовые devtools-хелперы (форматирование причин изменений, фильтрация событий, React-хуки).

Этот README описывает:

  • архитектуру runtime-core;
  • структуру файлов и модулей;
  • ключевые сущности (snapshot, команды, actions, components, devtools, dataSources);
  • как runtime-core используется в builder-web;
  • как он будет использоваться в отдельном runtime-host SPA;
  • план развития runtime-core и runtime-host.

1. Назначение runtime-core

Runtime-core обеспечивает общий исполнительный слой для DSL-приложений:

  • принимает на вход сгенерированный бандл (TSX/JSX/TS/JS-файлы);
  • компилирует его в JS-модули и исполняет в изолированном окружении;
  • предоставляет реестр визуальных компонентов (React-компоненты);
  • предоставляет реестр действий (actions) для императивных операций;
  • ведёт runtime-состояние приложения (глобальный state, состояние страниц, источники данных);
  • принимает и обрабатывает типизированные команды (RuntimeCommand);
  • уведомляет хост (builder-web / runtime-host SPA) о всех изменениях через RuntimeChangeReason и devtools-подписки;
  • делегирует фактическое выполнение источников данных во внешний executor (RuntimeDataSourceExecutor).

Главная цель: один рантайм для всех окружений, чтобы DSL-приложение вело себя одинаково в превью и в настоящем runtime-host.


2. Структура пакета

packages/runtime-core/
├─ src/
│ ├─ index.ts # Публичный API: типы, createRuntimeInstance, хуки, devtools
│ ├─ core/
│ │ ├─ compileBundle.ts # TSX/JSX/TS/JS → CommonJS JS-модули (через @babel/standalone)
│ │ ├─ executeBundle.ts # Выполнение JS-модулей и поиск App-компонента
│ │ └─ moduleLoader.ts # In-memory загрузчик модулей (require + module.exports)
│ ├─ react/
│ │ ├─ RuntimeRoot.tsx # React-обёртка над App-компонентом + RuntimeReactContext
│ │ ├─ createReactRuntimeInstance.ts # Реализация createRuntimeInstance для React
│ │ └─ runtimeHooks.ts # Набор React-хуков для snapshot/state/dataSources
│ ├─ state/
│ │ └─ runtimeState.ts # Внутреннее состояние рантайма, snapshot + listeners + команды
│ ├─ runtime/
│ │ ├─ defaultDataSourceExecutor.ts # Дефолтный executor источников данных с поддержкой моков
│ ├─ types/
│ │ └─ runtimeDataSourceExecutor.ts # Публичный контракт для executor’а источников данных
│ ├─ devtools/
│ │ └─ runtimeDevtools.ts # Devtools-хелперы: форматтер, type-guards, subscribeToRuntimeEvents
│ └─ components/ # (опционально) dev-компоненты для превью
├─ dist/
├─ package.json
└─ README.md # Текущий файл

Структура может немного меняться по мере развития, но основные слои остаются теми же: core (компиляция/исполнение), state/runtime (состояние и команды), react (обёртка и хуки), runtime (executor источников данных), devtools (инструменты для инспектора и логов).


3. Ключевые сущности

3.1. RuntimeSnapshot

RuntimeSnapshot — это «снимок» текущего состояния рантайма.

Он включает:

export interface RuntimeSnapshot {
globalState: Record<string, unknown>;
pageState: Record<string, Record<string, unknown>>;
activePageId: string | null;
dataSources: Record<string, unknown>;
}
  • globalState — глобальное состояние приложения (аналог appState в DSL);
  • pageState — состояние по страницам (pageId → state);
  • activePageId — текущая активная страница (или null);
  • dataSources — состояние источников данных.

Состояние dataSources хранится по ключу dataSourceId и может включать:

  • произвольные поля результата (items, user, total и т.п.);
  • служебные meta-поля, которые заполняет runtime-core:
    • __lastCallAt: number — последний момент вызова callDataSource;
    • __lastRefetchAt: number — последний момент refetchDataSource;
    • __lastPayload: unknown — последний payload;
    • __lastResultAt: number — последний успешный результат executor’а;
    • __lastErrorAt: number — последний момент ошибки;
    • __lastError?: string — текст последней ошибки.

Модель может быть уточнена в будущих версиях (например, явные статусы loading/success/error).

3.2. RuntimeCommand

Команда, которую внешний код может отправить в рантайм:

type RuntimeCommand =
| { type: 'navigate'; pageId: string }
| { type: 'setState'; scope: 'global' | 'page'; pageId?; path: string[]; value: unknown }
| { type: 'refetchDataSource'; dataSourceId: string }
| { type: 'callDataSource'; dataSourceId: string; payload?: unknown }
| { type: 'reset' }
| { type: 'custom'; name: string; payload?: unknown };

Команды обрабатываются на уровне state/runtimeState.ts (функция applyRuntimeCommand), которая:

  • формирует новый snapshot;
  • записывает изменение в нужную область (globalState, pageState, dataSources, activePageId);
  • вызывает updateRuntimeSnapshot, который уведомляет всех подписчиков.

3.3. RuntimeChangeReason

Причина, по которой изменился snapshot. Используется для devtools и внешних подписчиков:

  • init — первичная инициализация;
  • stateChanged — изменение глобального или страничного состояния;
  • dataSourceCallStarted — запрос на получение данных из источника;
  • dataSourceChanged — изменения, связанные с источниками данных;
  • navigation — навигация между страницами;
  • custom — произвольное пользовательское событие.

Devtools-утилиты (formatRuntimeChangeReason, type-guards) работают поверх этого типа.

3.4. RuntimeContext

Контекст выполнения рантайма:

export interface RuntimeContext {
components: RuntimeComponentsRegistry;
actions: RuntimeActionsRegistry;
}
  • components — реестр React-компонентов (имя → компонент);
  • actions — реестр действий (имя → функция).

Этот контекст инжектируется в sandbox при выполнении скомпилированного бандла.

3.5. RuntimeInstance

Экземпляр рантайма — то, что получает хост (builder-web, runtime-host SPA):

export interface RuntimeInstance {
RootComponent: ComponentType;
getSnapshot(): RuntimeSnapshot;
subscribe(listener: (snapshot: RuntimeSnapshot, reason: RuntimeChangeReason) => void): () => void;
dispatch(command: RuntimeCommand): void;
}

Создаётся через createRuntimeInstance(options).

3.6. React-хуки

Пакет экспортирует набор React-хуков для работы с рантаймом:

  • useRuntimeSnapshot() — подписка на целый RuntimeSnapshot;
  • useRuntimeDispatch() — доступ к dispatch из React-дерева;
  • useGlobalState(path?: string[]) — чтение/подписка на часть globalState;
  • usePageState(path?: string[], pageId?: string) — чтение/подписка на state страницы;
  • usePageStateObject(pageId?: string) — получение всего state страницы;
  • useDataSource(id: string) — чтение состояния конкретного DataSource по id.

Они работают поверх общего контекста RuntimeReactContext, который создаётся в RuntimeRoot.

3.7. Devtools-хелперы

В devtools/runtimeDevtools.ts находятся утилиты:

  • formatRuntimeChangeReason(reason) — возвращает англоязычную строку с описанием причины изменения (для логов/UI);

  • type-guards:

    • isNavigationChange(reason);
    • isStateChangedReason(reason);
    • isDataSourceChangedReason(reason);
    • isCustomChangeReason(reason);
  • subscribeToRuntimeEvents(runtime, options) — обёртка над runtime.subscribe с фильтрацией по видам событий и произвольному predicate.


4. Пайплайн исполнения

Полный путь от DSL до живого React-приложения выглядит так:

DSL → @lowcode/dsl-compiler → TSX-бандл
→ @lowcode/runtime-core.compileBundleToJsModules → JS CommonJS-модули
→ @lowcode/runtime-core.executeBundle → AppComponent
→ @lowcode/runtime-core.createRuntimeInstance → RuntimeInstance.RootComponent

4.1. compileBundleToJsModules

  • Принимает GeneratedBundleLike — набор файлов filename + content (TSX/JSX/TS/JS);
  • компилирует их через @babel/standalone с пресетами react, typescript и плагином transform-modules-commonjs;
  • возвращает in-memory карту CommonJS-модулей.

4.2. moduleLoader

  • Эмулирует require + module.exports;
  • разрешает только относительные импорты;
  • мапит импорт react на реальный React;
  • делает доступными:
    • все компоненты из RuntimeContext.components как top-level имена;
    • объект actions из RuntimeContext.actions как top-level переменную actions.

4.3. executeBundle

  • Создаёт loader над скомпилированными модулями;
  • загружает модуль App.tsx (по соглашению);
  • ищет экспорт App или default;
  • возвращает AppComponent или null при ошибке.

4.4. createRuntimeInstance

  • компилирует бандл в JS-модули;

  • исполняет бандл и получает AppComponent;

  • создаёт InternalRuntimeState:

    • snapshot + initialSnapshot;
    • listeners для подписок;
    • dataSourcesRegistry и dataSourceExecutor (если переданы);
  • формирует RuntimeContext (components + actions);

  • создаёт RootComponent, который оборачивает AppComponent в RuntimeRoot и пробрасывает в него пропсы хоста;

  • возвращает RuntimeInstance с RootComponent, getSnapshot, subscribe, dispatch.


5. Источники данных и RuntimeDataSourceExecutor

5.1. Интерфейс executor’а

Runtime-core не знает, как выполнять HTTP/DB/WebSocket — он лишь делегирует выполнение во внешний executor.

Контракт:

export interface RuntimeDataSourceExecutor {
execute(options: {
snapshot: RuntimeSnapshot;
action: CallDataSourceAction;
dataSource: DataSource;
}): Promise<unknown>;
}

Где:

  • snapshot — актуальное состояние на момент вызова (можно использовать activePageId, state и т.п.);
  • action — DSL-экшн callDataSource (восстановлен из команды);
  • dataSource — описание источника данных из DSL (kind, config, mock).

5.2. Поведение applyRuntimeCommand для DataSources

Команда callDataSource:

  1. Обновляет snapshot.dataSources[dataSourceId]:

    • проставляет __lastCallAt и __lastPayload;
  2. Генерирует RuntimeChangeReason с kind: 'dataSourceChanged';

  3. Если указан dataSourceExecutor и он знает этот dataSourceId:

    • вызывает executor.execute({ snapshot: nextSnapshot, action, dataSource });
    • по Promise:
      • при успехе объединяет результат с meta-полями и обновляет snapshot.dataSources;
      • при ошибке проставляет __lastErrorAt и __lastError;
  4. Если у CallDataSourceAction есть assignResultToStateKey и есть activePageId, результат записи:

    • раскладывается по пути, либо пишется под ключ assignResultToStateKey в pageState[activePageId].

Команда refetchDataSource:

  • обновляет __lastRefetchAt для нужного dataSourceId;
  • генерирует RuntimeChangeReason с kind: 'dataSourceChanged'.

5.3. createDefaultDataSourceExecutor

В runtime/defaultDataSourceExecutor.ts находится базовая реализация executor’а, ориентированная на preview в builder-web.

Особенности:

  • static dataSource без мока → всегда возвращает config.data;

  • rest / другие виды без mock.enabled === true → бросают осмысленную ошибку вида:

    REST data source "UsersAPI" (id=users-ds) has no mock and cannot be executed in preview. In production runtime-host is expected to perform HTTP request: ... To use this data source in builder preview, configure a mock in the Data Sources panel.

  • поддерживает mock в DSL (DataSource.mock):

    • mode: 'static'

      • если mock.value задан → возвращает mock.value;
      • если нет, то:
        • для kind: 'static' → используется config.data;
        • для остальных → null;
    • mode: 'sequence'

      • sequence: any[] — цикл значений по dataSourceId;
      • хранит счётчик по каждому источнику;
      • пустая sequence → возвращает null;
    • mode: 'error'

      • если есть mock.errorMessage → кидает её;
      • иначе — дефолтное сообщение REST data source "<name>" (id=<id>) mock error (mode="error").;
    • delayMs

      • если указан, executor делает искусственную задержку ответа через setTimeout (симуляция сети).

Важный момент: createDefaultDataSourceExecutor не выполняет реальных HTTP-запросов. Для production runtime-host ожидается отдельный executor, который будет реализовывать настоящие REST-вызовы.


6. Devtools и подписки на события

runtime-core предоставляет API для devtools:

  • formatRuntimeChangeReason(reason) — человеко-читаемое англоязычное описание причины изменения snapshot’а;

  • type-guards по видам причин:

    • isNavigationChange(reason);
    • isStateChangedReason(reason);
    • isDataSourceChangedReason(reason);
    • isCustomChangeReason(reason);
  • subscribeToRuntimeEvents(runtime, options) — обёртка над runtime.subscribe, которая позволяет:

    • слушать только интересующие kinds;
    • дополнительно фильтровать события через predicate;
    • получать (snapshot, reason) только для нужных событий.

Пример:

const unsubscribe = subscribeToRuntimeEvents(runtimeInstance, {
kinds: ['navigation', 'dataSourceChanged'],
predicate: (reason) => reason.kind === 'dataSourceChanged' && reason.dataSourceId === 'ds-users',
listener: (snapshot, reason) => {
console.log(formatRuntimeChangeReason(reason));
},
});

В связке с React-хуками (useRuntimeSnapshot, useDataSource, и т.д.) этого достаточно, чтобы собрать собственную панель devtools: лог событий, инспектор состояния, просмотр dataSources.


7. Использование в builder-web (preview)

В превью builder-web (PreviewPanel) runtime-core используется так:

  1. Валидация DSL (структура + выражения).

  2. Компиляция DSL → TSX-бандл через @lowcode/dsl-compiler.

  3. Построение начального состояния:

    • initialAppState из app.appState;
    • initialPageState для активной страницы из page.state;
    • initialDataSourcesState (по умолчанию пустые записи или прединициализация).
  4. Создание runtime-инстанса:

    const runtimeInstance = createRuntimeInstance({
    bundle,
    components: runtimeComponents,
    actions: runtimeActions,
    initialState: {
    globalState: initialAppState,
    pageState: activePageId ? { [activePageId]: initialPageState } : {},
    activePageId,
    dataSources: initialDataSourcesState,
    },
    dataSources: app.dataSources ?? [],
    dataSourceExecutor: createDefaultDataSourceExecutor(),
    onError: (err) => {
    console.error('[PreviewPanel] Runtime error:', err);
    },
    });
  5. Монтаж runtimeInstance.RootComponent в панель превью.

  6. Использование:

    • useRuntimeSnapshot и других хуков внутри дерева превью (например, в RuntimeStateInspector);
    • subscribeToRuntimeEvents с formatRuntimeChangeReason для панели Runtime events.

Так превью ведёт себя как минимальный runtime-host, полностью основанный на @lowcode/runtime-core.


8. Использование в runtime-host SPA (план)

Отдельное приложение runtime-host SPA будет тонкой обёрткой над runtime-core.

Предполагаемый сценарий:

  1. Получить от backend’а DSL или уже скомпилированный бандл.

  2. При необходимости — прогнать DSL через @lowcode/dsl-compiler (на сервере или на клиенте).

  3. Собрать реестр компонентов на базе @lowcode/ui-kit:

    import * as UI from '@lowcode/ui-kit';

    const runtimeComponents = {
    Container: UI.Panel,
    Text: UI.Text,
    Button: UI.Button,
    Input: UI.Input,
    // ... остальные DSL-компоненты
    };
  4. Собрать реестр actions, завязанный на реальные сервисы:

    • navigate — через SPA-роутер;
    • callDataSource / refetchDataSource — через HTTP-клиент (axios/fetch), обновляющий dataSources;
    • showToast — через продакшн UI-тостер;
    • работа с auth/storage и т.п.
  5. Создать RuntimeInstance через createRuntimeInstance и смонтировать RootComponent в корень SPA.

  6. По желанию — подключить devtools-панель поверх runtime-core (state/events/dataSources).

Вся тяжёлая логика исполнения (команды, snapshot, dataSources, devtools) уже реализована в runtime-core и переиспользуется между builder-web и runtime-host.


9. Тестирование

Текущая структура тестов для runtime-core:

  • core-слой — тесты компиляции и загрузки:

    • compileBundleToJsModules (обработка TSX/JSX/TS/JS);
    • moduleLoader (относительные импорты, кеширование, виртуальный react);
    • executeBundle (поиск App/default, обработка ошибок).
  • state/runtime-слой — тесты работы со snapshot’ом:

    • инициализация начального состояния по initialState;
    • обработка команд (navigate, setState, callDataSource, refetchDataSource, reset);
    • поведение при ошибках executor’а (запись __lastError).
  • DataSources — тесты defaultDataSourceExecutor:

    • static dataSource без моков → config.data;
    • rest без моков → осмысленная ошибка с подсказкой про runtime-host и моки;
    • mock.mode = 'static' | 'sequence' | 'error' + delayMs;
    • корректное поведение при mock.enabled === false.
  • React-оболочка — тесты createReactRuntimeInstance и RuntimeRoot:

    • корректный проброс пропсов в AppComponent;
    • наличие RuntimeReactContext в дереве;
    • интеграция dispatch и подписок.
  • devtools — тесты runtimeDevtools:

    • formatRuntimeChangeReason для всех стандартных kind;
    • корректность type-guards;
    • фильтрация событий в subscribeToRuntimeEvents.

Запуск тестов (через Jest в монорепо):

pnpm --filter @lowcode/runtime-core test

10. Резюме

@lowcode/runtime-core — это единый движок выполнения DSL-приложений, который:

  • принимает сгенерированный TSX/JSX-бандл от @lowcode/dsl-compiler;
  • компилирует и исполняет его в контролируемом окружении;
  • управляет runtime-состоянием (global/page/dataSources/activePageId);
  • обрабатывает типизированные команды (navigate, setState, callDataSource, refetchDataSource, reset, custom);
  • делегирует работу с источниками данных в RuntimeDataSourceExecutor (с дефолтной реализацией через моки);
  • предоставляет удобный контракт для хоста (RuntimeInstance);
  • даёт devtools-хелперы и хуки для инспекции состояния и событий;
  • переиспользуется в превью builder-web и в будущем runtime-host SPA.

Всё, что связано с UI, backend’ом и конкретным окружением, живёт снаружи runtime-core. Здесь находятся только общие абстракции и алгоритмы, обеспечивающие предсказуемое поведение DSL-приложений во всех средах выполнения.