Introductionâ
Source plugins are the default and recommended extension mechanism in LinaPro. They are compiled and deployed together with the core framework as Go source code, using pluginhost to register routes, hooks, scheduled tasks, lifecycle callbacks, and governance logic. They are well suited for business modules that require long-term maintenance, high performance, and a full engineering experience.
Official source plugins live in apps/lina-plugins/. The main repository mounts official plugin workspaces through this directory; user projects also maintain their own business plugins here.
Use Casesâ
| Scenario | Source Plugin Recommended? | Reason |
|---|---|---|
| Long-term business modules | Yes | Testable, auditable, best performance |
| Organization, content, monitoring, and other backend capabilities | Yes | Tight integration with core framework permissions, menus, scheduling, and multi-tenancy |
| Runtime hot-reloading | Not preferred | Source plugins require rebuilding and redeploying the core framework |
| Commercial binary distribution | Not preferred | Source plugins typically expose source code |
Standard Directory Structureâ
apps/lina-plugins/<plugin-id>/
âââ plugin.yaml # Plugin metadata and capability declarations
âââ plugin_embed.go # Plugin source embedding for host compilation entry
âââ Makefile # Plugin make command entry
âââ backend/ # Plugin backend source code
â âââ api/ # API DTOs and route contracts
â âââ internal/ # Plugin internal business logic
â â âââ controller/ # HTTP controllers
â â âââ service/ # Business service layer
â â âââ dao/ # Generated by make dao
â â âââ model/ # do/entity models
â âââ pkg/ # Plugin-exposed capabilities
â âââ plugin.go # Plugin registration entry point
âââ frontend/ # Plugin frontend resources
â âââ pages/ # Plugin pages
â âââ slots/ # Slot pages, optional
âââ hack/ # Plugin scripts and tools
â âââ config.yaml # Plugin development tool config entry, including code generation and custom build configuration
â âââ tests/ # Plugin test content
â âââ e2e/ # Plugin e2e test content
âââ manifest/ # Plugin manifest and resources
â âââ config/ # Plugin runtime configuration
â â âââ config.yaml # Development-time default config
â â âââ config.example.yaml # Config template, not used as runtime default
â âââ sql/ # Installation and upgrade SQL
â â âââ mock-data/ # Demo data, optional
â â âââ uninstall/ # Uninstallation SQL
â âââ i18n/ # Plugin language packs
âââ README.md # Plugin documentation
âââ README.zh-CN.md # Plugin Chinese documentation
backend/internal/service/ is the designated location for plugin service logic. Do not create an additional service/ package at the plugin root or backend/ root.
Plugin Manifestâ
plugin.yaml declares the plugin's identity, runtime form, multi-tenancy boundary, menus, and permissions:
id: linapro-content-notice
name: Content Notice
version: v0.1.0
type: source
scope_nature: tenant_aware
supports_multi_tenant: true
default_install_mode: tenant_scoped
description: Provides publish and subscribe capabilities for content change notifications
author: linapro
license: Apache-2.0
menus:
- key: plugin:linapro-content-notice:list
name: Content Notice
path: linapro-content-notice-list
component: system/plugin/dynamic-page
perms: content-notice:notice:view
icon: ant-design:notification-outlined
type: M
sort: 1
- key: plugin:linapro-content-notice:create
parent_key: plugin:linapro-content-notice:list
name: Create Notice
perms: content-notice:notice:create
type: B
Plugin IDs are recommended to use a three-segment kebab-case structure: <author>-<domain>-<capability>. For example, in linapro-content-notice, linapro is the author, content is the domain, and notice is the capability. The <domain> segment should be chosen from common business domains such as content, monitor, org, tenant, auth, oidc, ai, storage, workflow, and message. See Plugin System for a complete list. Menu keys must be globally unique; the plugin:<plugin-id>:<key> format is recommended. Button permissions use type: B and are nested under menus rather than appearing directly in the sidebar.
Database and SQLâ
Installation SQL files are located in manifest/sql/, and uninstallation SQL files are in manifest/sql/uninstall/. Installation and upgrade scripts must be idempotent, typically using patterns like CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS linapro_content_notice_record (
"id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"tenant_id" INT NOT NULL DEFAULT 0,
"title" VARCHAR(255) NOT NULL DEFAULT '',
"content" TEXT NOT NULL DEFAULT '',
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_linapro_content_notice_record_tenant
ON linapro_content_notice_record ("tenant_id");
Plugin tables that need to support multi-tenancy should include a tenant_id column. When multi-tenancy is not enabled for a plugin, tenant_id = 0 represents the platform context.
API and Service Layerâ
Plugin APIs also use g.Meta to declare paths, methods, permissions, and documentation:
type NoticeListReq struct {
g.Meta `path:"/notices" method:"get" tags:"Notice" summary:"List notices" permission:"content-notice:notice:view"`
Page int `json:"page" v:"min:1"`
PageSize int `json:"pageSize" v:"min:1,max:100"`
}
Here, path is the relative path after the controller is bound to the plugin route group. Source plugin business APIs should be mounted uniformly under /x/{plugin-id}/... rather than occupying the core framework's /api/v1 control plane. For example, the above endpoint in the linapro-content-notice plugin is recommended to be exposed as:
/x/linapro-content-notice/notices
The service layer accesses the database through its own DAO. When tenant isolation is required, use the source-plugin-specific capability published by the core framework via Services().TenantFilter() to append tenant conditions, rather than writing inconsistent filtering rules manually.
Registration Entry Pointâ
Source plugins register in backend/plugin.go through init():
func init() {
plugin := pluginhost.NewDeclarations("linapro-content-notice")
plugin.Assets().UseEmbeddedFiles(contentnotice.EmbeddedFiles)
plugin.HTTP().RegisterRoutes(
pluginhost.ExtensionPointHTTPRouteRegister,
pluginhost.CallbackExecutionModeBlocking,
registerRoutes,
)
plugin.Jobs().RegisterJobs(
pluginhost.ExtensionPointJobsRegister,
pluginhost.CallbackExecutionModeBlocking,
registerJobs,
)
plugin.Lifecycle().RegisterBeforeUpgradeHandler(beforeUpgrade)
plugin.Lifecycle().RegisterAfterUpgradeHandler(afterUpgrade)
plugin.Access().RegisterMenuFilter(menuFilter)
plugin.Access().RegisterPermissionFilter(permissionFilter)
pluginhost.RegisterSourcePlugin(plugin)
}
In full plugin mode, the core framework generates an aggregation entry point that blank-imports configured plugins, bringing their init() registration logic into the core framework process.
Route Registrationâ
HTTPRegistrar.Routes() returns the plugin's route registrar. The registrar's APIPrefix() currently returns the plugin-specific namespace /x/{plugin-id}; subsequent path segments are organized by the plugin:
func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) error {
routes := registrar.Routes()
middlewares := routes.Middlewares()
routes.Group(routes.APIPrefix(), func(group pluginhost.RouteGroup) {
group.Middleware(
middlewares.NeverDoneCtx(),
middlewares.HandlerResponse(),
middlewares.CORS(),
middlewares.RequestBodyLimit(),
middlewares.Ctx(),
)
group.Group("/", func(group pluginhost.RouteGroup) {
group.Middleware(
middlewares.Auth(),
middlewares.Tenancy(),
middlewares.Permission(),
)
group.Bind(articleController)
})
})
return nil
}
Source plugins can still register non-reserved public routes such as /portal/..., /assets/..., or / for portal pages, self-managed static files, or SPA fallbacks. These routes are not plugin APIs and will not be automatically projected as workspace menus, permissions, or OpenAPI endpoints. Source plugins must not register paths belonging to other plugins under /x; conflicting or out-of-bounds paths will be rejected at startup.
Plugin Configuration and Manifest Resourcesâ
Source plugins obtain plugin-scoped framework services through registrar.Services(). Capabilities related to configuration and manifest resources include:
| Service | Purpose | Design Reference |
|---|---|---|
Plugins().Config() | Reads the current plugin's own configuration; production override path is plugins/<plugin-id>/config.yaml under the production config root; development-time default is manifest/config/config.yaml | Plugin Governance Capability |
HostConfig() | Reads host-convention configuration keys such as workspace.basePath, i18n.default, and i18n.enabled | Configuration Management Capability |
Manifest() | Reads raw resources from the current plugin's manifest/ directory, such as profile.yaml, config/config.example.yaml, or i18n/zh-CN/plugin.json | Manifest Resource Capability |
manifest/config/config.example.yaml is only a template and does not participate in default value reading. Plugins should not scan the host's complete configuration tree via g.Cfg(), nor should they write plugin business configuration into the core framework's config.yaml. For the complete configuration reading priority, see Plugin Business Configuration. For manifest resource path semantics and dedicated resource pipeline boundaries, see Manifest Delivery Resources. For architecture design and usage constraints of each domain capability, see Plugin Domain Capabilities Overview.
func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) error {
services := registrar.Services()
timeout, err := services.Plugins().Config().Duration(ctx, "sync.timeout", 5*time.Second)
if err != nil {
return err
}
_ = timeout
workspaceBase, err := services.HostConfig().String(ctx, "workspace.basePath", "/admin")
if err != nil {
return err
}
_ = workspaceBase
var profile struct {
Category string `yaml:"category"`
}
if err := services.Manifest().Scan(ctx, "profile.yaml", "", &profile); err != nil {
return err
}
return nil
}
The profile.yaml here is just an ordinary YAML resource example. The path is relative to manifest/ and should not be written as manifest/profile.yaml.
Frontend Pagesâ
Source plugin frontend pages are located in frontend/pages/ and loaded by the core framework workspace's dynamic page shell. The component in plugin menus typically uses:
component: system/plugin/dynamic-page
Pages can reuse the default workspace's frontend ecosystem and design conventions. When a plugin is disabled, the core framework's menu API no longer returns that plugin's entry, and the workspace sidebar automatically hides it.
Source plugins can also declare public_assets to have the core framework host their public static resources at /x-assets/{plugin-id}/{version}/.... Only resource directories explicitly declared in plugin.yaml will be made public. Tenant files, user private files, installation scripts, and configuration files should not be placed in public_assets.
Event Hooks and Scheduled Tasksâ
Source plugins can subscribe to core framework events such as successful login, plugin enablement, system startup, and more. Hooks can be synchronous and blocking or asynchronous, depending on the execution mode chosen at registration time.
Plugins can also register their own scheduled task handlers, which become available when administrators create tasks from the admin workspace:
plugin.Jobs().RegisterJobs(
pluginhost.ExtensionPointJobsRegister,
pluginhost.CallbackExecutionModeBlocking,
func(registry pluginhost.JobsRegistry) error {
registry.Register("content-notice:cleanup", cleanupExpiredNotices)
return nil
},
)
Runtime Upgradesâ
When source plugin files are updated, the core framework compares the effective version in the database with the currently discovered version. If a higher version is discovered, the plugin enters the pending_upgrade runtime state. The core framework's basic governance capabilities remain available, but the plugin's business entry points enter a controlled state.
Administrators perform an explicit runtime upgrade from the plugin management page. The upgrade process re-reads the effective and target manifests, performs dependency checks, executes BeforeUpgrade callbacks, runs plugin-specific upgrade logic, executes upgrade SQL, synchronizes governance resources, switches the effective version, and invalidates caches. On failure, the plugin enters upgrade_failed state, allowing diagnosis and retry.
This model prevents file overwrites from being mistakenly treated as completed data and governance resource upgrades.
Best Practicesâ
- Use the three-segment
kebab-casestructure<author>-<domain>-<capability>for plugin IDs, and the correspondingsnake_caseas the database table prefix. - Installation and upgrade SQL must be idempotent to avoid failures when reinstalling after data preservation.
- Place service logic in
backend/internal/service/. - Plugins should only use stable contracts like
pluginhostandpluginservice, not directly depend on the core framework'sinternal/packages. - Multi-tenant plugin tables should include a
tenant_idcolumn and use the core framework's tenant filtering service. - Declare menus and button permissions together to avoid pages being visible without corresponding operation permissions.
- When uninstalling, distinguish between governance records, database data, and file data to avoid accidental deletion.