Introductionâ
Source plugins are the default and recommended extension mode in LinaPro. They are compiled and deployed alongside the core framework as Go source code, using pluginhost to register routes, hooks, scheduled tasks, lifecycle callbacks, and governance logic. This mode is well suited for business modules that require long-term maintenance, high performance, and a complete engineering experience.
Official source plugins live under apps/lina-plugins/. The main repository mounts this directory as the official plugin workspace; user projects also maintain their own business plugins in the same location.
When to Use Source Pluginsâ
| Scenario | Recommended? | Reason |
|---|---|---|
| Long-term business modules | Yes | Testable, auditable, best native performance |
| Administration, content, monitoring, and similar back-office capabilities | Yes | Tight integration with the core framework's permissions, menus, scheduling, and multi-tenancy |
| Runtime hot-loading | Not preferred | Source plugins require a full rebuild and redeploy of 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_embed.go
âââ backend/
â âââ api/ # API DTOs and route contracts
â âââ hack/
â â âââ config.yaml # Plugin development config (e.g. make dao)
â âââ internal/
â â âââ controller/ # HTTP controllers
â â âââ service/ # Business service layer
â â âââ dao/ # Generated by make dao
â â âââ model/ # do/entity models
â âââ plugin.go # Plugin registration entry point
âââ frontend/
â âââ pages/ # Plugin pages
â âââ slots/ # Slot pages (optional)
âââ manifest/
â âââ config/
â â âââ config.yaml # Development-time default configuration
â â âââ config.example.yaml # Configuration template (not a runtime default)
â âââ sql/ # Installation and upgrade SQL
â â âââ mock-data/ # Demo data (optional)
â â âââ uninstall/ # Uninstallation SQL
â âââ i18n/ # Plugin language packs
âââ README.md
backend/internal/service/ is the canonical location for plugin service logic. Do not create a separate service/ package at the plugin root or the backend/ root.
Plugin Manifestâ
plugin.yaml declares the plugin's identity, runtime form, multi-tenant boundaries, menus, and permissions:
id: content-article
name: Article Management
version: v0.1.0
type: source
scope_nature: tenant_aware
supports_multi_tenant: true
default_install_mode: tenant_scoped
description: Provides CRUD management for article content
author: linapro
license: Apache-2.0
menus:
- key: plugin:content-article:list
name: Article Management
path: content-article-list
component: system/plugin/dynamic-page
perms: content-article:article:view
icon: ant-design:file-text-outlined
type: M
sort: 1
- key: plugin:content-article:create
parent_key: plugin:content-article:list
name: Create Article
perms: content-article:article:create
type: B
Menu key values must be globally unique. Use the plugin:<plugin-id>:<menu-key> naming convention. Button permissions are attached to a menu via type: B and do not appear directly in the sidebar.
Database and SQLâ
Plugin installation scripts live in manifest/sql/, and uninstallation scripts live in manifest/sql/uninstall/. Installation and upgrade scripts must be idempotent â use CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, and similar patterns.
CREATE TABLE IF NOT EXISTS content_article_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_content_article_record_tenant
ON content_article_record ("tenant_id");
Tables that need multi-tenant support should include a tenant_id column. When the multi-tenant plugin is not enabled, tenant_id = 0 represents the platform context.
API and Service Layerâ
Plugin API definitions also use g.Meta to declare paths, methods, permissions, and documentation:
type ArticleListReq struct {
g.Meta `path:"/articles" method:"get" tags:"Article" summary:"List articles" permission:"content-article:article:view"`
Page int `json:"page" v:"min:1"`
PageSize int `json:"pageSize" v:"min:1,max:100"`
}
The path here is the relative path after the controller is bound to the plugin route group. Source plugin business APIs should be mounted under /x/{plugin-id}/... rather than occupying the core framework /api/v1 control plane. For example, the endpoint above is recommended to be exposed as:
/x/linapro-content-article/articles
The service layer accesses the database through the plugin's own DAO. When tenant isolation is required, use the core framework's TenantFilterService to append tenant filter conditions instead of writing inconsistent filtering rules manually.
Registration Entry Pointâ
Source plugins register via init() in backend/plugin.go:
func init() {
plugin := pluginhost.NewSourcePlugin("linapro-content-article")
plugin.Assets().UseEmbeddedFiles(contentarticle.EmbeddedFiles)
plugin.HTTP().RegisterRoutes(
pluginhost.ExtensionPointHTTPRouteRegister,
pluginhost.CallbackExecutionModeBlocking,
registerRoutes,
)
plugin.Cron().RegisterCron(
pluginhost.ExtensionPointCronRegister,
pluginhost.CallbackExecutionModeBlocking,
registerCronJobs,
)
plugin.Lifecycle().RegisterBeforeUpgradeHandler(beforeUpgrade)
plugin.Lifecycle().RegisterAfterUpgradeHandler(afterUpgrade)
pluginhost.RegisterSourcePlugin(plugin)
}
In full plugin mode, the core framework generates an aggregate entry point and blank-imports all 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}; the remaining path segments are organized by the plugin itself:
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 may also register non-reserved public routes such as /portal/..., /assets/..., or / for portal pages, self-managed static files, or SPA fallback. These routes are not part of the plugin's API surface and will not automatically appear as workspace menus, permissions, or OpenAPI endpoints. Source plugins must not register paths belonging to other plugins under /x; conflicting or out-of-bound paths will be rejected at startup.
Plugin Configuration and Manifest Resourcesâ
Source plugins obtain plugin-scoped core framework services through registrar.HostServices(). The capabilities related to configuration and manifest resources are:
| Service | Purpose | Architecture |
|---|---|---|
Config() | Reads the current plugin's own configuration. The production override path is plugins/<plugin-id>/config.yaml under the production configuration root; the development-time default is manifest/config/config.yaml | ConfigService |
HostConfig() | Reads allowlisted public host configuration keys such as workspace.basePath, i18n.default, and i18n.enabled | HostConfigService |
Manifest() | Reads raw resources under the current plugin's manifest/ directory, such as profile.yaml, config/config.example.yaml, or i18n/zh-CN/plugin.json | ManifestService |
manifest/config/config.example.yaml is only a template and is not used in default reads. Plugins should not scan the host's full configuration tree through g.Cfg(), nor should they write plugin business configuration into the core framework config.yaml. For the full configuration read priority, manifest resource path semantics, and dedicated resource pipeline boundaries, see Plugin Configuration and Manifest Resources. For the architecture and usage constraints of each capability service, see Plugin Capability Services Overview.
func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) error {
services := registrar.HostServices()
timeout, err := services.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
}
Here, profile.yaml is just an example of a plain YAML resource. Paths are relative to manifest/ and should not be written as manifest/profile.yaml.
Frontend Pagesâ
Source plugin frontend pages live in frontend/pages/ and are loaded by the core framework workspace's dynamic page shell. The component field 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 menu API stops returning that plugin's entries, and the workspace sidebar hides them automatically.
Source plugins can also declare public_assets to let the core framework serve publicly accessible static resources from the plugin at /x-assets/{plugin-id}/{version}/.... Only directories explicitly declared in plugin.yaml are published; 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, and system startup. Hooks can execute synchronously with blocking or asynchronously, depending on the execution mode chosen at registration time.
Plugins can also register their own scheduled task handlers for the admin workspace to select when creating tasks:
plugin.Cron().RegisterCron(
pluginhost.ExtensionPointCronRegister,
pluginhost.CallbackExecutionModeBlocking,
func(registry pluginhost.CronRegistry) error {
registry.Register("content-article:cleanup", cleanupExpiredArticles)
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 found, the plugin enters the pending_upgrade runtime state â the core framework's basic governance capabilities remain available, while the plugin's business entry points enter a controlled state.
The administrator performs an explicit runtime upgrade from the plugin management page. The upgrade flow re-reads the effective manifest and target manifest, runs dependency checks, executes the BeforeUpgrade callback, runs the plugin's custom upgrade logic, applies upgrade SQL, synchronizes governance resources, switches the effective version, and invalidates caches. On failure, the plugin enters upgrade_failed, where diagnostic information is available and a retry can be attempted.
This model avoids the assumption that overwriting files means the data and governance resources have been fully upgraded.
Best Practicesâ
- Use
kebab-casefor pluginIDs and the correspondingsnake_caseprefix for database table names. - Make installation and upgrade
SQLidempotent to prevent failures when reinstalling with retained data. - Place service logic in
backend/internal/service/. - Depend only on stable contracts such as
pluginhostandpluginservice; do not directly import the core framework'sinternal/packages. - Include a
tenant_idcolumn in multi-tenant plugin tables and use the core framework's tenant filtering service. - Declare menus and button permissions together to avoid pages being visible while actions lack the required permissions.
- When uninstalling, distinguish between governance records, database data, and file data to prevent accidental deletion.