Skip to main content
Version: 0.2.x

Introduction​

Source plugins are LinaPro's recommended default extension method. 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 suited for long-term business modules that require high performance and a complete engineering experience.

Official source plugins live in apps/lina-plugins/. The main repository mounts the official plugin workspace through this directory. User projects also maintain their own business plugins here.

When to Use​

ScenarioRecommended?Reason
Long-term business modulesYesTestable, auditable, best performance
Organization, content, monitoring capabilitiesYesTight integration with core framework permissions, menus, scheduling, and multi-tenancy
Runtime hot-loadingNot 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_embed.go
├── backend/
│ ├── api/ # API DTOs and route contracts
│ ├── hack/
│ │ └── config.yaml # Plugin development config for make dao and related commands
│ ├── internal/
│ │ ├── controller/ # HTTP controllers
│ │ ├── service/ # Business service layer
│ │ ├── dao/ # Generated by make dao
│ │ └── model/ # do/entity models
│ └── plugin.go # Plugin registration entry
├── frontend/
│ ├── pages/ # Plugin pages
│ └── slots/ # Optional slot pages
├── 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/ # Uninstall SQL
│ └── i18n/ # Plugin language packs
└── README.md

backend/internal/service/ is the fixed location for plugin service logic. Do not create a service/ package at the plugin root or backend/ root.

Plugin Manifest​

plugin.yaml declares plugin identity, runtime form, multi-tenant boundaries, menus, and permissions:

id: content-article
name: 文įĢ įŽĄį†
version: v0.1.0
type: source
scope_nature: tenant_aware
supports_multi_tenant: true
default_install_mode: tenant_scoped
description: 提䞛文įĢ å†…åŽšįš„åĸžåˆ æ”šæŸĨįŽĄį†åŠŸčƒŊ
author: linapro
license: Apache-2.0
menus:
- key: plugin:content-article:list
name: 文įĢ įŽĄį†
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: 创åģēæ–‡įĢ 
perms: content-article:article:create
type: B

Menu key values must be globally unique. Use the plugin:<plugin-id>:<menu-key> format. Button permissions are attached under menus via type: B and do not appear in the sidebar directly.

Database and SQL​

Installation SQL is located in manifest/sql/, and uninstall SQL 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");

Plugin 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"`
}

Here, path is relative to the plugin route group where the controller is bound. Source plugin business APIs should be mounted under /x/{plugin-id}/..., not under the core framework /api/v1 control plane. In a linapro-content-article plugin, the interface 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 needed, use the core framework-published TenantFilterService to append tenant conditions rather than hand-writing inconsistent filter rules.

Registration Entry​

Source plugins register in backend/plugin.go through init():

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)
}

The core framework generates an aggregation entry in plugin-complete mode, blank-importing configured plugins so their init() registration logic enters the core framework process.

Route Registration​

HTTPRegistrar.Routes() returns the plugin route registrar. The registrar's APIPrefix() currently returns the plugin-specific namespace /x/{plugin-id}, and remaining path segments are owned 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, custom static files, or SPA fallbacks. These routes are not plugin APIs and are not automatically projected as workspace menus, permissions, or OpenAPI interfaces. Source plugins must not register another plugin's path under /x; startup rejects conflicting or out-of-bound paths.

Plugin Configuration and Manifest Resources​

Source plugins use registrar.HostServices() to access plugin-scoped core framework services. Configuration and manifest resources are important additions in recent versions:

ServicePurpose
Config()Reads the current plugin's own configuration; the production override path is plugins/<plugin-id>/config.yaml, and the development default is manifest/config/config.yaml
HostConfig()Reads allowlisted public host configuration keys such as workspace.basePath, i18n.default, and i18n.enabled
Manifest()Reads declaration resources under the current plugin's manifest/, such as metadata.yaml

manifest/config/config.example.yaml is only a template and is not part of default reads. Plugins should not scan the full host configuration tree through g.Cfg(), and should not put plugin business configuration into the core framework config.yaml.

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 metadata struct {
Category string `yaml:"category"`
}
if err := services.Manifest().Scan(ctx, "metadata.yaml", "", &metadata); err != nil {
return err
}
return nil
}

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 no longer returns that plugin's entry, and the workspace sidebar hides it automatically.

Source plugins can also declare public_assets so the core framework hosts plugin-owned public static resources under /x-assets/{plugin-id}/{version}/.... Only directories explicitly declared in plugin.yaml are 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 — for example, login success, plugin enablement, and system startup. Hooks can be synchronous-blocking or asynchronous, depending on the execution mode chosen at registration.

Plugins can also register their own scheduled task handlers for administrators to select when creating tasks in the admin workspace:

plugin.Cron().RegisterCron(
pluginhost.ExtensionPointCronRegister,
pluginhost.CallbackExecutionModeBlocking,
func(registry pluginhost.CronRegistry) error {
registry.Register("content-article:cleanup", cleanupExpiredArticles)
return nil
},
)

Runtime Upgrade​

After source plugin files are updated, the core framework compares the active version in the database with the currently discovered version. When a higher version is found, the plugin enters the pending_upgrade runtime state. Core framework base governance capabilities remain available, while plugin business entry points enter a controlled state.

The administrator executes an explicit runtime upgrade in the plugin management page. The upgrade flow re-reads the active and target manifests, runs dependency checks, BeforeUpgrade callbacks, plugin custom upgrade logic, upgrade SQL, governance resource synchronization, active version switching, and cache invalidation. On failure, it enters upgrade_failed — diagnostics can be reviewed and the upgrade retried.

This model prevents file overwrites from being mistaken for completed data and governance resource upgrades.

Best Practices​

  • Use kebab-case for plugin ID and the corresponding snake_case for database table prefixes.
  • Installation and upgrade SQL must be idempotent — avoid failures when reinstalling with retained data.
  • Place service logic in backend/internal/service/.
  • Use only stable contracts like pluginhost and pluginservice — do not depend on the core framework's internal/ packages directly.
  • Reserve a tenant_id column in multi-tenant plugin tables and use the core framework tenant filter service.
  • Declare menus and button permissions together to avoid pages being visible but operations lacking permission.
  • Distinguish governance records, database data, and file data during uninstallation to avoid accidental deletion.