# Plugin Development Development

Screenshots on this page are taken from the Chinese UI. Menu, field and button names in the text use the English UI labels.

Plugins are one of the extension mechanisms the platform provides. With plugins you can add new features to the platform or modify existing ones. A plugin can contain Java/Groovy code, seed data (CSV files for domain classes, forms, menus, dynamic logic and so on) and frontend components.

# Plugin Management

A plugin is a file ending in .myp. Menu: Development > Plugins.

Dynamic plugin list

The list shows the name, version, description, enabled state, system flag, dependent plugins and other information of the imported plugins. PlatformAdapter is a built-in platform plugin; do not disable it.

The Is system column corresponds to the plugin's isSystem property. It is only a flag: the current platform source code does not treat plugins differently based on it, so it can be ignored.

# Importing a Plugin

Click Import dynamic plugin, upload the plugin file in the panel that opens, and click Submit in the upper-right corner:

Import plugin

The hint text in the upload area says "Drag file from disk to here and click upload button", but there is no upload button there: just drag the file into the upload area or click the upload area to select a file, then click Submit in the upper-right corner. This is a UI issue in the current version.

Field Description
Plugin file The plugin file (.myp)
Ignore MD5 Check Ignore the MD5 check when importing the plugin's seed data: when on, CSV files are re-imported even if they have not changed
Overwrite Conflict When on, forces an overwrite: the plugin is overwritten even if its version is not higher than the existing one, and seed data is imported without conflict detection, so data in the CSV overwrites data changed in the UI. This only applies to CSV files that are actually imported: CSV files whose content has not changed (same MD5) are skipped entirely by default, and are re-imported only if Ignore MD5 Check is also on. When the plugin file name contains SNAPSHOT, this is treated as on regardless of the setting

# Version and Overwrite Rules

  • Only one record is kept per plugin name. Importing a plugin with the same name updates that record; multiple versions do not coexist.
  • When the imported version is higher than the existing version, it overwrites the existing plugin and is enabled automatically.
  • When the imported version is equal to or lower than the existing version, it is silently ignored by default: there is no message in the UI, only a warning in the backend log. The following two cases are exceptions and force an overwrite:
    • The plugin file name contains SNAPSHOT;
    • Overwrite Conflict is turned on during import.
  • In these two cases, the plugin's seed data is also forcibly overwritten without conflict detection: as long as a CSV file's content has changed (or Ignore MD5 Check is turned on during import), data changed in the UI is overwritten by that CSV; CSV files whose content has not changed are still skipped entirely based on MD5. So plugins whose file names contain SNAPSHOT are only suitable for development environments.

Therefore, when releasing a new version of a plugin, always increase version in build.gradle.

# Enabling and Disabling

Select plugins in the list and click Enable dynamic plugins or Disable dynamic plugins. After enabling or disabling, the platform reloads the plugins; no restart is needed.

# Plugin Runtime

  • All plugins enabled on the platform share the same runtime environment, and plugins can call each other directly.
  • Plugins call platform capabilities through the tech.muyan:api library, see Platform API.

# Developing Plugins

# Target Audience

This document is mainly intended for advanced developers who want to learn how to develop plugins for the Muyan development platform.

# Prerequisites

Readers need some familiarity with Gradle. Reference: Gradle (opens new window).

# Build Environment

  • JDK 25: the plugin's Java toolchain is 25, and the JDK that runs Gradle itself must also be 25.
  • Gradle 9: the template ships with the Gradle Wrapper (9.2.0); just use ./gradlew.

WARNING

If the Gradle daemon runs on a JDK lower than 25, packaging fails with class file version 69.0 ... up to 65.0. First run ./gradlew --stop to stop the old daemon, make sure JAVA_HOME points to JDK 25, and rebuild. Do not lower the toolchain version.

# Creating a Plugin

  1. Create a project from the platform template repository muyantech/platform (a private repository; request access from Muyan).
  2. The plugin project is in the codes/ directory:
    • rootProject.name in codes/settings.gradle is the plugin name;
    • version in codes/build.gradle is the plugin version;
    • codes/src/ holds the plugin's Java/Groovy source code;
    • codes/data/ holds the plugin's seed data (csv/, groovy/ and so on), which is packaged into the plugin as a whole; the CSV files in data/plugin-csv/ are merged into csv/ in the package, and data/plugins/ stores the built .myp files and is not packaged;
    • codes/ui-component/ is the plugin's frontend component project (optional).
  3. Add third-party dependencies through build.gradle and start writing business logic.

# Build Configuration

The template's codes/build.gradle already contains the following key configuration (an excerpt; the template is authoritative for the full content, for example the project-level repositories, test and coverage configuration are not listed). Usually you only need to change the version, dependencies and dependsOnPlugins:

buildscript {
  repositories {
    mavenCentral()
    maven {
      url = uri('http://packages.muyan.io/artifactory/libs-release')
      allowInsecureProtocol = true
    }
    maven {
      url = uri('http://packages.muyan.io/artifactory/libs-snapshot')
      allowInsecureProtocol = true
    }
  }
  dependencies {
    classpath 'gradle.plugin.com.github.harbby:gradle-serviceloader:1.1.8'
    classpath 'tech.muyan:gradle-plugin:1.0.0-1-2-SNAPSHOT'
  }
}

plugins {
  id 'java-library'
  id 'groovy'
}
apply plugin: 'tech.muyan.gradle.plugin'

version '0.0.1'
group 'tech.muyan.plugin'

dependencies {
  // Provided by the platform runtime; must be compileOnly and must not be packaged into the plugin
  compileOnly localGroovy()
  compileOnly 'tech.muyan:api:0.0.5'

  // The plugin's own runtime dependencies, packaged into the plugin
  implementation 'commons-io:commons-io:2.7'
}

muyanPlatformPlugin {
  compileGroovyScript false
  // PlatformAdapter is a built-in platform plugin; do not remove it
  dependsOnPlugins([
    'PlatformAdapter': '0.0.1',
  ])
}

// Re-stage the data/ directory before packaging so that packaging still works after clean
tasks.register('stageMuyanData', Sync) {
  from(rootProject.file('data')) {
    exclude 'plugin-csv/**'
    exclude 'plugins/**'
  }
  from(rootProject.file('data/plugin-csv')) {
    into 'csv'
  }
  into(layout.buildDirectory.dir('tmp/muyan'))
}

tasks.named('buildDynamicFramePackage') {
  dependsOn(tasks.named('stageMuyanData'))
}

// The packaging plugin writes an empty dependsOnPlugins as JSON "[]", but the platform needs "{}"; replace it after generation
tasks.named('generateMuyanPluginInfo') {
  doLast {
    def pluginInfo = file("${layout.buildDirectory.get().asFile}/tmp/muyan/PLUGIN_INFO")
    pluginInfo.text = pluginInfo.text.replace('"dependsOnPlugins":[]', '"dependsOnPlugins":{}')
  }
}

java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(25))
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

The last two blocks are the template's fixes for known issues in the packaging plugin (tech.muyan:gradle-plugin); do not remove them:

  • stageMuyanData: the packaging plugin copies data/ to build/tmp/muyan during the configuration phase, and this copy is deleted after running clean; this task copies it again before packaging and merges data/plugin-csv/ into csv/.
  • The doLast of generateMuyanPluginInfo: when dependsOnPlugins is empty, the packaging plugin writes it as [], but the platform needs {} when reading it.

The following can be configured in muyanPlatformPlugin:

Setting Default Description
dependsOnPlugins Empty Dependent plugins and their minimum versions; PlatformAdapter must be kept. The '0.0.1' in the template is a minimum version requirement; the PlatformAdapter currently built into the platform is 1.1.2, which satisfies it
compileGroovyScript false Whether to precompile the Groovy scripts referenced in DynamicLogic*.csv during packaging
devHost http://localhost:8080 Backend address for uploading plugins in development mode, see Development Mode
ignoreMd5Check false Whether to ignore the MD5 check when uploading plugins in development mode
overwriteConflict true Whether to force an overwrite when uploading plugins in development mode

# Data Binding

In plugin source code you can define data models (POJO classes) and bind them to platform domain classes. Just make sure the POJO class has the same name as the domain class and implements the tech.muyan.api.DynamicDomainEntity interface. The packaging plugin automatically generates ServiceLoader registration files for classes that implement DynamicDomainEntity and MuyanPlatformComponent, so you do not need to write them by hand.

Here is an example. First define the CSV files for DomainClass and DomainClassField:

# DomainClass

shortName(*),extInfo,createRoleRequirement.name,readRoleRequirement.name,updateRoleRequirement.name,deleteRoleRequirement.name

SampleDynamicOrderDomain,,USER,USER,USER,USER
1
2
3

# DomainClassField

domainClass.shortName(*),name(*),dataType,referenceDomain.shortName,nullable,editable,defaultValue,options,extInfo

SampleDynamicOrderDomain,orderId,STRING,,Y,Y,,,
SampleDynamicOrderDomain,isActive,BOOLEAN,,N,N,,,
SampleDynamicOrderDomain,totalAmount,BIG_DECIMAL,,Y,N,,,
SampleDynamicOrderDomain,quantity,INTEGER,,N,Y,,,
SampleDynamicOrderDomain,productId,LONG,,Y,Y,,"[1,2,3]",
SampleDynamicOrderDomain,discountRate,DOUBLE,,Y,Y,,,
SampleDynamicOrderDomain,orderDate,LOCAL_DATE,,Y,Y,,,
SampleDynamicOrderDomain,deliveryDateTime,ZONED_DATETIME,,Y,Y,,,
SampleDynamicOrderDomain,additionalInfo,JSON_STRING,,Y,Y,,,
SampleDynamicOrderDomain,buyerTask,DOMAIN_OBJECT,SampleTask,Y,Y,,,
SampleDynamicOrderDomain,sellerTasks,DOMAIN_OBJECT_LIST,SampleTask,Y,Y,,,
1
2
3
4
5
6
7
8
9
10
11
12
13

TIP

The four *RoleRequirement.name columns in the DomainClass CSV specify the role requirements for create, read, update and delete. When they are not configured, nobody can perform the operations; see Object Permission Control.

Next, define the POJO class:

package tech.muyan.plugin

import tech.muyan.api.DynamicDomainEntity

import java.time.LocalDate
import java.time.ZonedDateTime

class SampleDynamicOrderDomain implements DynamicDomainEntity {

  Long id

  String orderId

  Boolean isActive

  BigDecimal totalAmount

  Integer quantity

  Long productId

  Double discountRate

  LocalDate orderDate

  ZonedDateTime deliveryDateTime

  String additionalInfo

  // SampleTask is another POJO class defined elsewhere; it is only an example here and its definition is not shown.
  // If the SampleTask class is not defined, Object can be used instead. The same applies to sellerTasks below.
  SampleTask buyerTask

  List<SampleTask> sellerTasks

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# Using Data Model Binding

With the SimpleQuery utility class described in Platform API, you can operate on data through data model binding. For example

SampleDynamicOrderDomain domain = SimpleQuery
   .of(SampleDynamicOrderDomain.class)
   .eq("orderId", "123456")
   .get()
1
2
3
4

Or convert the queried data to a POJO object:

SampleDynamicOrderDomain domain = SimpleQuery
   .of("SampleDynamicOrderDomain")
   .eq("orderId", "123456")
   .get() as SampleDynamicOrderDomain
1
2
3
4

# Lifecycle Callbacks

Classes in a plugin that implement the tech.muyan.api.MuyanPlatformComponent interface receive callbacks after the plugin is loaded:

import tech.muyan.api.MuyanPlatformComponent;

public class CrmStartup implements MuyanPlatformComponent {

  @Override
  public void onLoad() {
    // Runs after the plugin finishes loading, for example to warm up caches or check required configuration
  }

  @Override
  public void offLoad() {
    // Runs when the plugin is unloaded (before reloading), for example to release thread pools and connections
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • After the platform finishes starting, it calls onLoad() for each tenant in turn; afterwards, whenever a plugin is reloaded because plugins are imported, enabled or disabled, or a jar is replaced in development mode, onLoad() is called again (in a multi-instance deployment, once on each instance), so the logic here must be safe to run repeatedly.
  • Before reloading, offLoad() is called on the old plugin instance first; the first load at platform startup does not call offLoad().
  • Both methods have default empty implementations; override only the one you need.
  • Catch exceptions yourself inside onLoad() / offLoad() and do not let them propagate. The platform does not guard the callbacks against exceptions. When an exception is thrown:
    • offLoad() throws: loading the new plugin code fails and the old code keeps running; the offLoad() of the components not yet executed in all of that tenant's plugins is also skipped. The import still reports success, and only Failed to replace classloader is recorded in the backend log.
    • onLoad() throws: the new code has been loaded, but the onLoad() of the components not yet executed in all of that tenant's plugins is skipped.
    • In both cases, the finishing steps after reloading are not executed, which affects all tenants on that backend instance: all endpoints of plugin custom Controllers return 404 (the routes were cleared before reloading and not rebuilt), related caches are not refreshed, and all scheduled tasks are paused (CRON tasks no longer fire). In a multi-instance deployment, the other instances do not receive the "loading complete" notification, and are likewise left with endpoints returning 404 and scheduled tasks paused, while still running the old code. The import still reports success.
    • Things recover only after the next successful reload (usually after fixing or disabling the faulty plugin first). When offLoad() fails, restarting the backend recovers it (offLoad() is not called at startup); if onLoad() throws every time, restarting the backend may not recover it either, because onLoad() is also called at startup.
  • The "Run at Startup" scheduled task type, removed in 1.0, can have its logic migrated into onLoad().

# Plugin Frontend Modules

A plugin can carry its own frontend components (React), which the platform frontend loads dynamically through Module Federation.

  1. Write the components in codes/ui-component/. The component project's manifest.ts default-exports a FrontendPluginManifest, registering custom form renderers in forms and field components in fields:

    import { FrontendPluginManifest } from '@muyantech/frontend-lib';
    import TestFormRender from './TestFormRender';
    
    export const manifest: FrontendPluginManifest = {
      fields: {},
      forms: {
        'TEST_FORM': {
          Render: TestFormRender,
        }
      }
    }
    
    export default manifest;
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
  2. Configure the build command and output directory in codes/ui-component/build.json (when omitted, the defaults are yarn && yarn build and build):

    {
      "buildCommand": "yarn && yarn build",
      "targetFolder": "dist"
    }
    
    1
    2
    3
    4
  3. Register the module in codes/data/csv/DisplayComponentModule.csv, with packageFile set to the frontend project's directory relative to codes/:

    name(*),packageFile
    
    Test Module,ui-component
    
    1
    2
    3

When the plugin is packaged, the build command runs in that directory, and the output directory is zipped into the plugin. After the plugin is imported, the platform tells the frontend which modules to load through modules[].entry (/theme/module/<hash>/mf-manifest.json) returned by GET /theme/info.

WARNING

/theme/info returns modules only when an active display theme exists. This section is based on the packaging plugin and the platform source code; the sample module in the template is commented out by default.

# WebSocket

A plugin can handle messages sent by the frontend over WebSocket and push messages to the frontend. Extend tech.muyan.api.websocket.MuyanWebSocketComponent:

import tech.muyan.api.websocket.MuyanWebSocketComponent

class EquipmentStatusSocket extends MuyanWebSocketComponent {

  @Override
  String getTopic() {
    return 'equipmentStatus'
  }

  @Override
  Object onMessage(String sessionId, String msgId, Object payload) {
    // The return value is sent back to the sender as the reply to this message
    return [received: true]
  }

  @Override
  void onClose(String sessionId) {
    // Cleanup when the connection closes
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • getTopic(): the message topic this component handles.
  • onMessage(sessionId, msgId, payload): called when a message on this topic is received; the return value is sent to the sender as the reply (with the same msgId).
  • pushMessage(sessionId, msgId, payload): actively sends a message to a connection.
  • broadcast(payload): sends a message to all connections subscribed to this topic.
  • onClose(sessionId): called when a connection closes; optional.

The client connection address is ws://<server address>/api/websocket/route?access_token=<access_token>. Messages are sent as JSON: {"msgId": "...", "type": "...", "topic": "equipmentStatus", "payload": {...}}, and are handled by the component for topic. To subscribe / unsubscribe (you only receive broadcast after subscribing), set type to subscribe / unsubscribe and put the topic list in payload.topics, for example {"msgId":"1","type":"subscribe","payload":{"topics":["equipmentStatus"]}}. Sending plain text ping returns pong.

api version

The platform runtime's onMessage returns Object, while the return type declared in api 0.0.5 is void. WebSocket components compiled against 0.0.5 do not work properly on the current platform; use the 1.0.0 series api library (see Platform API).

# Custom Controller

A plugin can register its own HTTP endpoints. Implement tech.muyan.api.MuyanDynamicController, specify the path prefix with @Mapping on the class, declare routes with @Get, @Post, @Put and @Delete on methods, and bind parameters with @UrlParameter (a {variable} in the path) or @QueryParameter (a URL parameter):

import tech.muyan.api.MuyanDynamicController
import tech.muyan.api.annotations.Get
import tech.muyan.api.annotations.Mapping
import tech.muyan.api.annotations.QueryParameter
import tech.muyan.api.annotations.UrlParameter

@Mapping('/equipment')
class EquipmentController implements MuyanDynamicController {

  @Get(value = '/{code}/status', roleRequirement = 'USER')
  Map status(@UrlParameter('code') String code, @QueryParameter('detail') String detail) {
    return [code: code, status: 'RUNNING', detail: detail == 'true']
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

The example above registers the endpoint GET /api/equipment/<code>/status?detail=true. All requests not matched by the platform's built-in routes are handed to the routes registered by plugins. The return value is converted to JSON.

Parameter values are converted from strings to the parameter types; it is recommended to declare parameters as String and parse them yourself. For example, when declared as Boolean, ?detail=false is also converted to true.

Security note

  • roleRequirement takes the name of a role requirement. If it is omitted, or set to a name that does not exist, the endpoint can be accessed without login. Explicitly set an existing role requirement for every endpoint.
  • These annotations and interfaces are only provided in the 1.0.0 series of the api library; they are not in api 0.0.5.
  • Do not end the value of @Mapping with / (for example /equipment/), otherwise the registered paths will be incorrect.

# Dynamic RPC

Plugins, or different platform instances, can call each other through dynamic RPC (based on gRPC):

  • Server side: in a plugin, implement an interface that extends tech.muyan.api.MuyanRpcService and provide an implementation class.
  • Client side: add the @RpcClient(host, port, tenant, password, caCertPath, serverName) annotation to the interface, then get a proxy object through DynamicRpcClientService.getClientProxy(Interface.class) and call it.

The server side is configured through the following environment variables:

Environment variable Description
DYNAMIC_RPC_SERVER_PORT Listening port, default 7777
DYNAMIC_RPC_SERVER_CERT_PATH TLS certificate path; TLS is enabled only when configured together with the next item
DYNAMIC_RPC_SERVER_KEY_PATH TLS private key path
DYNAMIC_RPC_PASSWORD Call password; the password of the client's @RpcClient must match it

Security note

The platform always starts the RPC service on port 7777 (or DYNAMIC_RPC_SERVER_PORT) at startup. When DYNAMIC_RPC_PASSWORD is empty, no authentication is performed, and without a certificate the communication is not encrypted. Anyone who can reach this port can call the services in plugins that implement MuyanRpcService. In production, be sure to:

  • Set DYNAMIC_RPC_PASSWORD;
  • Configure a TLS certificate;
  • Not expose the port to the public internet; restrict access sources with a firewall or container network.

In addition, the default value of password in @RpcClient is password; set it explicitly on the client side.

# Packaging a Plugin

When development is done, run in the codes/ directory:

./gradlew buildMuyanPlugin
1

The output is codes/build/muyan/<plugin name>-<version>.myp. Packaging runs the following in order:

  • generateMuyanPluginInfo: generates the plugin descriptor file PLUGIN_INFO (name, version, dependent plugins);
  • compileSeedDataGroovy: copies the plugin jar and runtime dependencies to libs/ in the package;
  • buildDynamicFramePackage: formerly used to build the frontend projects referenced by the frameFile column in DynamicForm*.csv. Since 1.0 the platform has removed the frameFile column, so this step no longer produces anything, but the task is kept (the template hooks stageMuyanData in before it);
  • buildFrontendPackage: builds the frontend modules;
  • packages the seed data under codes/data/ together.

implementation dependencies are packaged into libs/ of the plugin package; compileOnly dependencies (including tech.muyan:api) are not.

# Importing Plugins over HTTP in Development Mode

During development you can push plugins straight to the local backend without going through the UI:

./gradlew buildAndUploadMuyanPlugin   # Package and import the whole plugin
./gradlew hotReloadSourceJar          # Replace only the plugin's jar and reload the plugin
1
2

The target address is configured by devHost in muyanPlatformPlugin (default http://localhost:8080). The backend opens these endpoints only when the environment variable PLATFORM_DEV_MODE=true is set; otherwise they return 401 OperationInvalid.

Security note

The development-mode upload, import and jar replacement endpoints (/dev/upload/part, /dev/plugin/import, /dev/plugin/jar/replace) do not require login. With PLATFORM_DEV_MODE on, anyone who can reach the backend can upload and run arbitrary code. Only turn it on in a local development environment; never set PLATFORM_DEV_MODE in production.

Last Updated: 9/24/2026, 2:27:35 PM