Skip to main content

UI Engine Architecture

Overview

The Muonroi UI Engine is a schema-driven framework for building rule-authoring interfaces with zero-config runtime resolution. Built on TypeScript with Web Components (Lit), it provides 23 reusable custom elements that automatically bind to Muonroi rule definitions, decision tables, and flow graphs. The architecture prioritizes:

  • O(1) component lookup via indexed manifest maps
  • Framework agnostic — works in React, Angular, vanilla JS
  • ETag HTTP caching with TTL manifest invalidation
  • License-aware rendering — commercial components gate based on JWT tiers
  • Real-time schema sync — SignalR watchers push changes to all clients

Manifest-Driven Architecture

At startup, the UI Engine bootstraps a single source of truth: the MUiEngineManifest, a JSON schema defining all screens, actions, data sources, navigation, and component bindings.

graph LR
A["Control Plane API<br/>(GET /ui-manifest)"] -->|JSON| B["MUiEngineBootstrapper<br/>(5 phases)"]
B -->|TTL: 60s| C["MUiEngineRuntime<br/>(4 indexed maps)"]
C -->|O1 lookups| D["Component Resolver<br/>(Lit + React)"]
D -->|Query| E["SignalR SchemaWatcher<br/>(live invalidation)"]
E -->|schema_changed| B
style C fill:#4a90e2,color:#fff
style E fill:#7ed321,color:#fff

Runtime Maps (O(1) Lookup)

MUiEngineRuntime constructs 4 indexed maps during bootstrap for instant component resolution:

MapKeyValueLookup Time
mScreensByRouteroute stringMUiEngineScreenO(1)
mScreensByKeyscreen.screenKeyMUiEngineScreenO(1)
mActionsByKeyaction.actionKeyMUiEngineActionO(1)
mDataSourcesByKeyds.dataSourceKeyMUiEngineDataSourceO(1)

Visibility and enablement checks use standalone helper functions:

  • MCanRenderScreen(screen) — checks isVisible + parent visibility
  • MCanRenderAction(action) — checks isVisible
  • MCanExecuteAction(action) — checks isEnabled

Bootstrap Lifecycle (5 Phases)

The MUiEngineBootstrapper initializes the runtime with layered caching and contract validation:

// Phase 1: Determine cache key
const cacheKey = `ui-manifest:${userId}:${tenantId}`

// Phase 2: TTL lookup
const cached = mCache.get(cacheKey)
if (cached && cached.expiresAtEpochMs > Date.now()) {
return cached.manifest // 60s default TTL
}

// Phase 3: Fetch fresh manifest
const manifest = await mProvider.LoadManifestAsync(manifestId)

// Phase 4: Build runtime + store in cache
const runtime = new MUiEngineRuntime(manifest)
mCache.set(cacheKey, { manifest, expiresAtEpochMs })

// Phase 5: Attach schema watcher
await mWatcher.SubscribeAsync()
mWatcher.OnSchemaChanged = async () => {
mCache.delete(cacheKey)
runtime = await LoadCurrentRuntime(forceRefresh: true)
}

Telemetry events: cache_hit, cache_miss, manifest_loaded (with elapsed ms), manifest_invalid, schema_changed

Manifest Schema (v1 and v2)

The MUiEngineManifest defines the complete shape:

interface MUiEngineManifest {
schemaVersion: 'v1' | 'v2' // Contract versioning
screens: MUiEngineScreen[] // Route-addressable pages
actions: MUiEngineAction[] // Global action registry
dataSources: MUiEngineDataSource[] // API endpoints + connectors
navigationGroups: MUiEngineNavigationGroup[]
componentRegistry: { // Component manifest overrides
[componentKey: string]: {
disabled?: boolean
visibility?: 'always' | 'never' | 'conditional'
}
}
appShell: { // Layout + theme config
layoutType: 'sidebar' | 'topbar'
theme: 'light' | 'dark' | 'auto'
}
authProfile: MUiEngineAuthProfile // Token sourcing + refresh
apiContracts: MUiEngineApiContract[] // OpenAPI-like specs
ruleBindings: { // Rule element ↔ API mapping
[ruleKey: string]: {
screenKey?: string
actionKeys?: string[]
}
}
generationHints?: {} // For AI-powered schema gen
}

Key relationships:

  • Navigation nodes → screens via screenKey
  • Screens → components → dataSourceKey + actionKeys
  • Actions defined globally, reused across screens
  • Rule bindings connect rules to UI elements

Component Inventory

The UI Engine ships with 23 Lit custom elements (all prefixed mu-):

Core Components (OSS)

ComponentPurpose
mu-rule-explorerTree view of rules + folder hierarchy
mu-feel-editorFEEL expression editor with syntax highlighting
mu-rule-test-runnerExecute single rule with test inputs
mu-upgrade-promptLicense tier upsell component
mu-quota-indicatorVisual quota usage gauge

Decision Table Components (Commercial)

ComponentPurpose
mu-decision-tableMain table editor with virtualized rendering
mu-decision-table-listBrowse all decision tables
mu-dt-header-rowEditable column headers
mu-dt-data-rowData entry rows, 44px height
mu-dt-cellIndividual cell with FEEL evaluation
mu-dt-hit-policy-selectorHit policy dropdown (First/Unique/Collect/Priority)
mu-dt-column-configColumn type + I/O configuration
mu-dt-version-diffSide-by-side diff viewer

Rule Flow Components (Commercial)

ComponentPurpose
mu-rule-flow-designerVisual flow editor (Lit + React hybrid)
mu-nrules-editorNRules DSL editor with syntax check
mu-nrules-conditionCondition node inspector
mu-nrules-actionAction node inspector

Advanced Components (Commercial)

ComponentPurpose
mu-cep-window-configComplex event processing window setup
mu-cep-event-streamStream event simulation
mu-feel-playgroundInteractive FEEL expression tester
mu-feel-autocompleteFEEL context-aware suggestions
mu-rule-trace-viewerStep-through execution debugger
mu-rule-result-panelFormatted result display
mu-schema-watcherReal-time schema change detector
mu-ui-engine-appFull-featured rule authoring shell

Decision Table Widget Deep Dive

The mu-decision-table component is the most complex, optimized for 1000+ row tables:

Virtualized Rendering

  • Row height: Fixed 44px (no variable heights)
  • Viewport: 420px visible area
  • Rendered rows: ~45 visible at a time (10-row buffer above/below)
  • Spacers: top/bottom DOM elements to maintain scroll position
  • Memory: O(1) for 10,000 rows (only 45 in DOM)

Undo/Redo Stack

  • Limit: 50 actions max per session
  • Trigger: every mutation calls MCommitHistory() BEFORE state change
  • Stack: past[] (clone of state), future[] (for redo)
  • Normalization: MNormalizeTable() deep-clones via JSON to ensure immutability

Version Diff View

// Load two versions in parallel
const v1 = await api.GetDecisionTableVersion(tableId, 5)
const v2 = await api.GetDecisionTableVersion(tableId, 6)

// Server-side diff (POST /diff)
const diff = await api.DiffVersions(tableId, 5, 6)
// Returns: { addedRows: [], deletedRows: [], changedCells: [] }

// Render side-by-side with highlights

Store Integration (Zustand)

// Factory pattern: MCreateDecisionTableStore()
const store = zustand.createStore<DecisionTableStore>(
(set, get) => ({
table: null,
past: [],
future: [],

loadTable: async (id) => {
const table = await api.GetDecisionTable(id)
set({ table, past: [], future: [] })
},

updateCell: (rowIdx, colIdx, value) => {
const clone = JSON.parse(JSON.stringify(get().table))
get().past.push(clone) // Commit BEFORE mutation
clone.rows[rowIdx].cells[colIdx].value = value
set({ table: clone, future: [] })
api.SaveDecisionTable(clone) // Async, fire-and-forget
},

undo: () => {
if (get().past.length === 0) return
const prev = get().past.pop()
get().future.push(get().table)
set({ table: prev })
}
})
)

Rule Flow Designer (Lit + React Hybrid)

The flow designer uses an unconventional pattern: Lit shadow DOM host with React subtree inside.

export class MuRuleFlowDesigner extends LitElement {
private mReactRoot: React.Root

override connectedCallback() {
super.connectedCallback()

// Mount React component inside shadow DOM
const shadowRoot = this.shadowRoot
const container = document.createElement('div')
shadowRoot.appendChild(container)

this.mReactRoot = createRoot(container)
this.mReactRoot.render(
<MuRuleFlowEditor
graph={this.mGraph}
onChange={this.handleGraphChange.bind(this)}
onPublish={this.handlePublish.bind(this)}
/>
)
}

private handleGraphChange(newGraph: MRuleFlowGraph) {
this.mInternalGraphUpdate = true // Prevent infinite loops
this.mGraph = newGraph
this.requestUpdate()
this.mInternalGraphUpdate = false
}

private async handlePublish() {
// Convert graph → RuleSet JSON
const ruleSet = await MRuleFlowGraphConverter.toRuleSetAsync(
this.mGraph,
this.workflowName
)

// Save via API
const resp = await this.apiClient.SaveRuleSet(ruleSet)

if (this.approvalRequired) {
// Submit for approval workflow
await this.apiClient.SubmitForApproval(resp.id)
} else {
// Direct activation
await this.apiClient.ActivateRuleSet(resp.id)
}
}
}

Graph Synchronization

  • Parse: MSyncGraphFromJson() validates incoming JSON
  • Signature: Stored to detect external changes (via SignalR)
  • Reload: MRuleFlowGraphConverter.fromRuleSet() reconstructs graph from rule JSON
  • Conflict: If signature mismatch detected, prompt user to reload

Zustand Vanilla Stores (Framework-Agnostic State)

All stateful components use Zustand vanilla (non-React) stores via the factory pattern:

// Decision table store
export const MCreateDecisionTableStore = () =>
createStore<DecisionTableStore>((set, get) => ({...}))

// Rule engine store
export const MCreateRuleEngineStore = () =>
createStore<RuleEngineStore>((set, get) => ({...}))

Benefits:

  • Works with Lit, React, Angular, vanilla JS (no coupling)
  • TypeScript generics for type safety
  • Unidirectional flow: store → component state subscription
  • Write via store.getState().action()

HTTP Caching Strategy

The MUiEngineApiClient implements two-tier caching:

ETag (HTTP 304)

// Store ETags per request path
private mETagMap = new Map<string, { etag: string; cached: T }>()

async get<T>(url: string): Promise<T> {
const cached = this.mETagMap.get(url)

const headers = {}
if (cached) {
headers['If-None-Match'] = cached.etag
}

const resp = await fetch(url, { headers })

if (resp.status === 304) {
return cached.cached // Server says: not modified
}

const data = await resp.json()
const etag = resp.headers.get('ETag')
this.mETagMap.set(url, { etag, cached: data })
return data
}

Request Headers

export interface MHttpRequestConfig {
Authorization: `Bearer ${string}`
'X-Tenant-Id': string
'Accept-Language': string
'X-Correlation-Id': string // crypto.randomUUID()
}

License Verification (MLicenseVerifier)

Commercial components check license tier at runtime using JWT validation:

export class MLicenseVerifier {
private mLicense: MUiEngineLicense | null = null

initialize(jwtToken: string) {
// Parse JWT: header.payload.signature
const [header, payload, signature] = jwtToken.split('.')

// Verify RS256 signature with public key
const verified = crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
publicKey,
base64urlToBuffer(signature),
new TextEncoder().encode(`${header}.${payload}`)
)

if (!verified) throw new Error('Invalid license')

// Decode payload
const claims = JSON.parse(base64urlDecode(payload))

// Check nbf (not before) and exp (expiration)
const now = Math.floor(Date.now() / 1000)
if (claims.nbf > now || claims.exp < now) {
throw new Error('License expired')
}

this.mLicense = {
tier: claims.tier, // free|licensed|enterprise
features: claims.features || [],
tenantId: claims.tenantId,
expiresAt: new Date(claims.exp * 1000)
}
}

hasFeature(key: string): boolean {
return this.mLicense?.features.includes(key) ?? false
}

hasAnyFeature(candidates: string[]): boolean {
return candidates.some(c => this.hasFeature(c))
}
}

Tiers:

  • Free — basic read-only, no rule editing
  • Licensed — standard features, rule editing allowed
  • Enterprise — all commercial components + advanced features

Commercial Feature Gating

The m-commercial-guard.ts module gates component visibility:

export function MCanRenderCommercialFeature(key: string): boolean {
const candidates = MResolveFeatureCandidates(key)
// Feature aliases: "decision-table" → ["rule-components.decision-table"]
// Wildcard: "*" in license features grants all
return MLicenseVerifier.hasAnyFeature(candidates)
}

export function MRenderCommercialLicenseGate(tier: string) {
return (
<div className="license-gate">
<p>Upgrade to {tier} to unlock this feature</p>
<a href={PRICING_URL}>View Plans</a>
</div>
)
}

Feature Aliases:

UI ComponentLicense Feature
mu-decision-tablerule-components.decision-table
mu-rule-flow-designerrule-components.rule-flow-designer
mu-feel-autocompleterule-components.feel-autocomplete
mu-rule-trace-viewerrule-components.rule-trace-viewer
All othersrule-components.*

SignalR Schema Watcher

Real-time schema changes are broadcast to all connected clients via SignalR:

export class MUiEngineSignalRSchemaWatcher {
private mConnection: HubConnection

constructor(private mEndpoint: string, private mAccessToken: string) {
this.mConnection = new HubConnectionBuilder()
.withUrl(mEndpoint, {
accessTokenFactory: () => mAccessToken,
headers: {
'X-Tenant-Id': getCurrentTenantId()
}
})
.withAutomaticReconnect()
.build()
}

async subscribe(): Promise<void> {
this.mConnection.on('SchemaChanged', (event: SchemaChangedEvent) => {
console.log(`Schema changed: ${event.changeType}`)
// Trigger full cache invalidation
MResetCache()
// Reload runtime
MLoadCurrentRuntime(forceRefresh: true)
})

await this.mConnection.start()
await this.mConnection.invoke('SubscribeToSchemaChanges')
}

async unsubscribe(): Promise<void> {
await this.mConnection.invoke('UnsubscribeFromSchemaChanges')
await this.mConnection.stop()
}
}

Integration: Bootstrapper attaches watcher in Phase 5, automatically reloads on schema changes.

Package Structure (8 npm Packages)

Open Source

PackagePurpose
@muonroi/m-ui-engine-coreRuntime, bootstrap, contracts
@muonroi/m-ui-engine-reactReact adapter + hooks
@muonroi/m-ui-engine-angularAngular route/menu mappers
@muonroi/m-ui-engine-primengPrimeNG component adapter

Commercial

PackagePurpose
@muonroi/m-ui-engine-rule-components23 Lit custom elements
@muonroi/m-ui-engine-signalrSignalR schema watcher
@muonroi/m-ui-engine-sync-toolingManifest sync utilities
@muonroi/m-ui-engine-license-toolsJWT verification helpers

All packages exported from monorepo with shared Zustand stores and license verification.

Data Flow Diagram

sequenceDiagram
participant User
participant Browser
participant ControlPlane as Control Plane API
participant SignalR as SignalR Hub
participant LicenseServer as License Server

User->>Browser: Load rule studio
Browser->>ControlPlane: GET /ui-manifest (If-None-Match: etag)
alt ETag hit
ControlPlane-->>Browser: 304 Not Modified
Browser->>Browser: Use cached manifest
else Cache miss
ControlPlane-->>Browser: 200 OK + manifest JSON + ETag
Browser->>Browser: Parse manifest → build runtime maps
end

Browser->>SignalR: WebSocket: SubscribeToSchemaChanges
SignalR-->>Browser: Connected + subscription confirmed

User->>Browser: Edit decision table cell
Browser->>Browser: MCommitHistory() + updateCell()
Browser->>ControlPlane: PUT /decision-tables/{id}
ControlPlane-->>Browser: 200 OK + newVersion

alt Schema changed remotely
SignalR->>Browser: SchemaChanged event
Browser->>Browser: MResetCache()
Browser->>ControlPlane: GET /ui-manifest (forceRefresh)
ControlPlane-->>Browser: 200 OK + new manifest
Browser->>Browser: rebuild runtime → refresh UI
end

User->>Browser: Publish rule
Browser->>ControlPlane: POST /rulesets/{code}
ControlPlane-->>Browser: Approval required
Browser->>ControlPlane: POST /approvals/{id}/submit
ControlPlane-->>Browser: 202 Accepted

Performance Characteristics

OperationTime ComplexityNotes
Component lookupO(1)4 indexed maps
Decision table renderO(45)Virtualized, 45 visible rows
TTL cache hitO(1)Epoch check + map lookup
ETag checkO(1)Header comparison
Manifest parseO(V+E)Schema validation (vertex + edges)
Undo/RedoO(1)Append + pop from arrays
Table diffO(rows)Server-side computation

Typical latencies (lab conditions):

  • Cold boot: 200-400ms (manifest fetch + parse)
  • Warm boot: 50-100ms (cache hit)
  • Cell update: 150-300ms (API round-trip)
  • Schema reload: 100-200ms (cache clear + re-init)

Cross-References

See Also