Skip to main content
Version: 0.5.x

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​

ScenarioSource Plugin Recommended?Reason
Long-term business modulesYesTestable, auditable, best performance
Organization, content, monitoring, and other backend capabilitiesYesTight integration with core framework permissions, menus, scheduling, and multi-tenancy
Runtime hot-reloadingNot preferredSource plugins require rebuilding and redeploying the core framework
Commercial binary distributionNot preferredSource 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:

ServicePurposeDesign 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.yamlPlugin Governance Capability
HostConfig()Reads host-convention configuration keys such as workspace.basePath, i18n.default, and i18n.enabledConfiguration 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.jsonManifest 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-case structure <author>-<domain>-<capability> for plugin IDs, and the corresponding snake_case as 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 pluginhost and pluginservice, not directly depend on the core framework's internal/ packages.
  • Multi-tenant plugin tables should include a tenant_id column 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.