Skip to main content
Version: 0.3.x(Latest)

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​

ScenarioRecommended?Reason
Long-term business modulesYesTestable, auditable, best native performance
Administration, content, monitoring, and similar back-office capabilitiesYesTight integration with the core framework's permissions, menus, scheduling, and multi-tenancy
Runtime hot-loadingNot preferredSource plugins require a full rebuild and redeploy of 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 (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:

ServicePurposeArchitecture
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.yamlConfigService
HostConfig()Reads allowlisted public host configuration keys such as workspace.basePath, i18n.default, and i18n.enabledHostConfigService
Manifest()Reads raw resources under the current plugin's manifest/ directory, such as profile.yaml, config/config.example.yaml, or i18n/zh-CN/plugin.jsonManifestService

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-case for plugin IDs and the corresponding snake_case prefix for database table names.
  • Make installation and upgrade SQL idempotent to prevent failures when reinstalling with retained data.
  • Place service logic in backend/internal/service/.
  • Depend only on stable contracts such as pluginhost and pluginservice; do not directly import the core framework's internal/ packages.
  • Include a tenant_id column 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.