Files
raisfast/docs/plugin-dev-guide.md
T
2026-04-29 16:45:12 +08:00

20 KiB
Raw Blame History

Plugin 开发指南

概述

Plugin 系统是 rust-blog 的运行时扩展机制,支持三种语言运行时,可独立于 Content Type 运行。Plugin 可以注册钩子、定时任务、自定义路由,并通过 Host API 访问数据库、HTTP、配置等受控资源。

架构

plugins/
  └── {plugin-id}/
       ├── manifest.toml    # 插件清单
       └── main.js          # 入口文件(JS/Lua/WASM)
              ↓ 启动加载
PluginManager(Arc 共享)
  ├─ 拓扑排序(依赖顺序)
  ├─ 实例池(round-robin 并发)
  └─ 热重载(文件系统监听)
              ↓ Hook 派发
Host API(沙箱权限控制)
  ├─ Host.dbQuery / dbExecute
  ├─ Host.dbBegin / dbCommit / dbRollback
  ├─ Host.httpGet / httpPost
  ├─ Host.getConfig
  ├─ Host.getData / setData(KV 存储)
  ├─ Host.fsRead / fsWrite / fsDelete / fsExists / fsList(VFS)
  ├─ Host.log / emitEvent
  └─ Host.newId

核心模块

模块 文件 职责
PluginManager src/plugins.rs 加载/卸载/hook 派发/热重载/事件总线
Manifest src/plugins/manifest.rs TOML 清单解析
Permissions src/plugins/permissions.rs 权限校验 + SQL 注入防护 + SSRF 防护
JS Engine src/plugins/engine_js.rs QuickJS 运行时 + 实例池
JS Host src/plugins/js_host.rs JS → Rust Host API 桥接
Lua Engine src/plugins/engine_lua.rs Lua 5.4 运行时 + 实例池
Lua Host src/plugins/lua_host.rs Lua → Rust Host API 桥接
WASM Engine src/plugins/engine.rs wasmtime 运行时
WASM Host src/plugins/host.rs WASM → Rust Host API 桥接
Host Common src/plugins/host_common.rs 共享 Host 逻辑
VFS src/plugins/vfs.rs 插件隔离虚拟文件系统
HTTP Client src/plugins/http_client.rs 插件 HTTP 请求
CLI src/cli/plugin_cmd.rs plugin new / plugin check

三种运行时

运行时 Cargo Feature 入口文件 引擎
JavaScript plugin-js main.js rquickjs (QuickJS)
Lua plugin-lua init.lua mlua (Lua 5.4)
WASM plugin-wasm plugin.wasm wasmtime

三种运行时可同时编译、同时加载。

实例池

每个插件创建多个运行时实例(由 PLUGIN_JS_POOL_SIZE / PLUGIN_LUA_POOL_SIZE / PLUGIN_WASM_POOL_SIZE 控制),以 round-robin 方式分发请求,避免并发瓶颈。

Manifest 文件 (manifest.toml)

最小示例

[plugin]
id = "com.example.my-plugin"
name = "My Plugin"
version = "0.1.0"
runtime = "js"
entry = "main.js"

[permissions]
max_memory_mb = 16
timeout_ms = 5000

[plugin] 字段

字段 类型 必填 默认值 说明
id string 是 — 插件唯一 ID(建议反向域名格式)
name string 是 — 显示名称
version string 是 — 语义版本号
description string 否 "" 描述
author string 否 — 作者
license string 否 — 许可证
runtime string 否 "wasm" 运行时:js / lua / wasm
language string 否 "rust" 语言标识
entry string 否 "index.js" 入口文件名
wasm string 否 "plugin.wasm" WASM 文件路径

[permissions] 权限声明

[permissions]
max_memory_mb = 16
timeout_ms = 5000
http = ["api.example.com", "*.github.com"]
config = ["app.*", "jwt.*"]
database = ["read:products", "write:orders", "categories"]
filesystem = ["read-write"]
字段 类型 默认 说明
max_memory_mb int 配置默认值 单实例内存上限
timeout_ms int 配置默认值 Hook 执行超时
http string[] [](禁止) HTTP 白名单
config string[] [](禁止) 配置读取白名单
database string[] [](禁止) 数据库权限
filesystem string[] [](禁止) 文件系统权限

数据库权限格式

格式 权限
"read:TABLE" 只读
"write:TABLE" 只写
"TABLE" 读写
"*" 所有表(受保护表除外)

HTTP 白名单

  • 精确域名:api.example.com
  • 通配符子域:*.github.com
  • 路径通配:api.example.com/v1/*

内置 SSRF 防护:自动阻止 localhost、127.x、10.x、172.16-31.x、192.168.x、169.254.x、::1。

受保护表

以下系统表即使声明 "*" 也不可访问:

users, roles, permissions, audit_log, plugin_storage, options,
rbac_roles, rbac_permissions, rbac_role_permissions, tenants

[hooks.XXX] 钩子注册

[hooks.on-content-created]
priority = 50

[hooks.on-content-updating]
priority = 100

[hooks.render-markdown]
priority = 10
字段 类型 默认 说明
priority int 100 优先级,数字越小越先执行

钩子名使用连字符(on-content-created),系统自动转换为下划线(on_content_created)。

17 种钩子

钩子名 类型 说明
on-content-creating filter 内容创建前,可修改数据
on-content-created action 内容创建后
on-content-updating filter 内容更新前,可修改数据
on-content-updated action 内容更新后
on-content-deleted action 内容删除后
on-content-viewed action 内容被浏览
on-post-creating filter 文章创建前(兼容)
on-post-created action 文章创建后(兼容)
on-post-updating filter 文章更新前(兼容)
on-post-updated action 文章更新后(兼容)
on-post-deleted action 文章删除后(兼容)
on-comment-creating filter 评论创建前(兼容)
on-comment-created action 评论创建后(兼容)
render-markdown filter Markdown 渲染覆盖(第一个返回 wins)
filter-html filter HTML 过滤
on-login action 用户登录后
on-cron-tick action 定时任务触发

filter 类型:可修改数据,返回值传递给下一个插件。 action 类型:仅副作用,返回值忽略。

[[cron]] 定时任务

[[cron]]
label = "每日统计"
job_type = "daily_stats"
cron_expr = "0 0 * * *"
payload = """{"type": "full"}"""
enabled = true
字段 类型 必填 说明
label string 是 任务名称
job_type string 是 任务类型(传给 on_cron_tick)
cron_expr string 是 Cron 表达式
payload string 否 附带数据
enabled bool 否 默认 true

[[routes]] 自定义路由

[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline"
handler = "getPipeline"
auth = "admin"

[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/contacts/:contactId"
handler = "getContact"
auth = "public"
字段 类型 必填 默认 说明
method string 是 — HTTP 方法
path string 是 — 路由路径,支持 :param 占位符
handler string 是 — 对应 Plugin 对象的函数名
auth string 否 default none / public / member / admin
description string 否 — 描述
permission string 否 — 额外权限要求

自定义路由的响应由框架统一包装为 { code: 0, message: "success", data: ... } 格式。

Host API

所有运行时通过统一的 Host.* 接口访问宿主功能:

数据库

// 参数化查询(防注入)
const rows = JSON.parse(Host.dbQuery("SELECT * FROM products WHERE price > ?", JSON.stringify([100])));
const affected = Host.dbExecute("UPDATE products SET stock = stock - 1 WHERE id = ?", JSON.stringify([id]));

// 事务
Host.dbBegin();
Host.dbExecute("INSERT INTO orders ...", null);
Host.dbExecute("UPDATE products ...", null);
Host.dbCommit();  // 或 Host.dbRollback()
函数 说明 权限
Host.dbQuery(sql, params?) SELECT 查询 database read
Host.dbExecute(sql, params?) INSERT/UPDATE/DELETE database write
Host.dbBegin() 开启事务 需要 pool
Host.dbCommit() 提交事务 活跃事务
Host.dbRollback() 回滚事务 活跃事务

注意:dbQuery 返回的整数列是 null,需用 CAST(col AS TEXT) 转为字符串后再 parseInt。

HTTP

const html = Host.httpGet("https://api.example.com/data");
const result = Host.httpPost("https://api.example.com/webhook", JSON.stringify({event: "test"}));

KV 存储

Host.setData("last_sync", "2026-01-01");
const val = Host.getData("last_sync");

每个插件有独立命名空间,互不干扰。

配置

const host = Host.getConfig("app.host");
const env = Host.getConfig("app.env");

允许的配置键(需在 permissions.config 白名单中): app.host, app.port, app.env, app.base_url, jwt.access_expires, jwt.refresh_expires, upload.dir, upload.max_size

文件系统(VFS)

Host.fsWrite("/reports/daily.json", reportJson);
const data = Host.fsRead("/reports/daily.json");
const exists = Host.fsExists("/reports/daily.json");
const files = Host.fsList("/reports");
Host.fsDelete("/reports/old.json");

每个插件在 {VFS_ROOT}/{plugin_id}/ 下有隔离沙箱,路径不能包含 ..。

其他

Host.log("info", "Processing order " + orderId);
Host.log("warn", "Low stock detected");
Host.log("error", "Payment failed: " + error);
Host.emitEvent("order.created", JSON.stringify({orderId: id}));
const id = Host.newId();  // UUID v7
函数 说明
Host.log(level, msg) 日志输出(level: info/warn/error)
Host.emitEvent(type, data) 发射事件到事件总线
Host.newId() 生成 UUID v7(时间排序,与系统主键一致)

编写 JS 插件(ES Module + SDK)

项目结构

plugins/my-plugin/
  ├── manifest.toml
  └── main.js

代码模板

import { dbQuery, dbExec, ok, fail, extractJson, logInfo, newId } from 'sdk';

// ── Hook ──

export function on_content_created(input) {
  const data = extractJson(input, "body");
  if (data?.content_type === "product") {
    logInfo("[my-plugin] new product: " + data.id);
  }
  return ok(data);
}

// ── 自定义路由 ──

export function getProduct(input) {
  const id = extractJson(input, "params.id");
  if (!id) return fail(400, "id required");
  const rows = dbQuery("SELECT * FROM products WHERE id = ?", [id]);
  if (!rows || rows.length === 0) return fail(404, "product not found");
  return ok(rows[0]);
}

// ── 定时任务 ──

export function on_cron_tick(input) {
  const data = extractJson(input, "body");
  if (data?.job_type === "daily_cleanup") {
    dbExec("DELETE FROM sessions WHERE expires_at < datetime('now')");
    logInfo("[my-plugin] daily cleanup done");
  }
}

SDK v1 API(import { ... } from 'sdk')

函数 说明
dbQuery(sql, params?) 参数化 SELECT 查询,返回对象数组;错误时抛异常
dbExec(sql, params?) INSERT/UPDATE/DELETE,返回 { error?, rows_affected }
dbBegin() 开启事务(失败时抛异常)
dbCommit() 提交事务(失败时抛异常)
dbRollback() 回滚事务
ok(data) 成功响应:返回数据,框架自动包装为 {code:0, data}
fail(status, msg) 错误响应:框架包装为 {code:N, message}
extractJson(input, field?) 从 JSON 中提取指定字段(支持 params.id 点号路径),不存在返回 null
logInfo(msg) / logWarn(msg) / logError(msg) 日志输出
newId() 生成 UUID v7(时间排序,与系统一致)
eventEmit(type, data) 发射事件到事件总线
httpGet(url) HTTP GET 返回原始字符串
httpGetJson(url) HTTP GET 并解析 JSON
httpPost(url, body) HTTP POST 返回原始字符串
httpPostJson(url, body) HTTP POST 并解析 JSON
configGet(key) 读取配置(需 config 权限)
storeGet(key) / storeSet(key, val) KV 存储
vfsRead(path) / vfsWrite(path, content) 虚拟文件系统读写
vfsDelete(path) / vfsExists(path) 虚拟文件系统删除/判断存在
vfsList(path) 列出目录下文件,返回数组

关键约定

  • 必须使用 export function 导出 handler(ES Module 模式,引擎自动收集到 Plugin 对象)
  • 路由处理:input 包含 { path, method, body, headers, params },直接 return ok(data) 或 return fail(status, msg)
  • Filter 钩子:接收 JSON 字符串 input,用 extractJson(input, "body") 提取数据
  • Action 钩子:接收 JSON 字符串,返回值被忽略
  • 支持 ES2024 完整语法(let/const、箭头函数、async/await、可选链等)
  • dbQuery() 查询失败时抛异常,可用 try/catch 捕获
  • dbQuery() 返回的整数列为 null,必须用 CAST(col AS TEXT) 转为字符串后再 parseInt

相对路径导入

import { helper } from './utils.js';

编写 Lua 插件(SDK 模式)

项目结构

plugins/my-plugin/
  ├── manifest.toml
  └── init.lua

代码模板

local sdk = require("sdk")

Plugin = {}

Plugin.on_content_created = function(input)
    local data = sdk.extractJson(input, "body")
    if data and data.content_type == "product" then
        sdk.logInfo("[my-plugin] new product: " .. tostring(data.id))
    end
    return sdk.ok(data)
end

Plugin.on_cron_tick = function(input)
    local data = sdk.extractJson(input, "body")
    if data.job_type == "daily_cleanup" then
        sdk.dbExec("DELETE FROM sessions WHERE expires_at < datetime('now')")
        sdk.logInfo("[my-plugin] cleanup done")
    end
end

Plugin.getStats = function(input)
    local result = sdk.dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt FROM products WHERE status = 'published'")
    return sdk.ok({ total = tonumber(result[1].cnt) or 0 })
end

SDK v1 API(local sdk = require("sdk"))

函数 说明
sdk.dbQuery(sql, params?) 参数化 SELECT 查询,返回数组表;错误时抛异常
sdk.dbExec(sql, params?) INSERT/UPDATE/DELETE,返回结果表
sdk.dbBegin() 开启事务(失败时抛异常)
sdk.dbCommit() 提交事务(失败时抛异常)
sdk.dbRollback() 回滚事务
sdk.ok(data) 成功响应:返回数据,框架自动包装
sdk.fail(status, msg) 错误响应:框架包装为 {code:N, message}
sdk.extractJson(input, field?) 从 JSON 中提取指定字段(支持点号路径),不存在返回 nil
sdk.logInfo(msg) / sdk.logWarn(msg) / sdk.logError(msg) 日志输出
sdk.newId() 生成 UUID v7(时间排序,与系统一致)
sdk.eventEmit(type, data) 发射事件
sdk.httpGet(url) HTTP GET 返回原始字符串
sdk.httpGetJson(url) HTTP GET 并解析 JSON
sdk.httpPost(url, body) HTTP POST 返回原始字符串
sdk.httpPostJson(url, body) HTTP POST 并解析 JSON
sdk.configGet(key) 读取配置
sdk.storeGet(key) / sdk.storeSet(key, val) KV 存储
sdk.vfsRead(path) / sdk.vfsWrite(path, content) 虚拟文件系统读写
sdk.vfsDelete(path) / sdk.vfsExists(path) 虚拟文件系统删除/判断存在
sdk.vfsList(path) 列出目录下文件,返回数组

关键约定

  • 必须导出全局 Plugin 表(Lua 不强制 ESM,但仍需 Plugin = {})
  • 使用 local sdk = require("sdk") 导入 SDK 模块
  • Filter 钩子:接收 Lua table,返回 Lua table
  • 路由处理:input 包含 { path, method, body, headers, params },直接 return sdk.ok(data) 或 return sdk.fail(status, msg)
  • 沙箱环境:仅暴露 table, string, math, utf8, coroutine 标准库(无 IO/OS/debug)
  • 指令限制:5,000,000 条
  • sdk.dbQuery() 查询失败时抛异常,可用 pcall 捕获
  • sdk.dbQuery() 返回的整数列在 Lua 中可能为 nil,建议用 CAST(col AS TEXT) 转换

错误恢复

  • 连续 5 次 错误自动禁用插件
  • 错误计数在成功执行时重置
  • 可通过 Admin API 手动重新启用
  • 插件超时/崩溃时自动回滚未提交的事务

热重载

当 PLUGIN_HOT_RELOAD=true(默认开启)时:

  1. 文件系统监听器监控插件目录的 .js / .lua / .wasm 文件变化
  2. 1 秒防抖
  3. 自动卸载 + 重新加载变化的插件
  4. 发出 PluginReloaded 事件

管理 API

方法 路径 说明
GET /api/v1/admin/plugins 列出所有插件(含状态/健康/指标)
GET /api/v1/admin/plugins/{id} 插件详情
POST /api/v1/admin/plugins/{id}/enable 启用
POST /api/v1/admin/plugins/{id}/disable 禁用
POST /api/v1/admin/plugins/{id}/reload 热重载
DELETE /api/v1/admin/plugins/{id} 卸载

CLI 命令

# 创建新插件
rust-blog plugin new my-plugin --runtime js    # JavaScript
rust-blog plugin new my-plugin --runtime lua   # Lua
rust-blog plugin new my-plugin --runtime wasm  # WASM

# 校验插件
rust-blog plugin check                      # 校验默认目录
rust-blog plugin check ./plugins/my-plugin  # 校验指定目录

环境变量

变量 默认值 说明
PLUGIN_DIR ./extensions/plugins 插件目录
PLUGIN_VFS_ROOT ./plugins-data VFS 根目录
PLUGIN_VFS_MAX_FILE_SIZE 1048576 单文件最大 1MB
PLUGIN_VFS_MAX_TOTAL_SIZE 10485760 总配额 10MB
PLUGIN_WASM_POOL_SIZE 4 WASM 实例池大小
PLUGIN_JS_POOL_SIZE 4 JS 实例池大小
PLUGIN_LUA_POOL_SIZE 4 Lua 实例池大小

完整示例:CRM 插件

plugins/crm/
  ├── manifest.toml
  └── main.js

manifest.toml:

[plugin]
id = "com.rust-blog.crm"
name = "CRM API"
version = "0.1.0"
description = "CRM 销售漏斗、Pipeline 管理、联系人时间线"
runtime = "js"
entry = "main.js"

[permissions]
max_memory_mb = 16
timeout_ms = 5000
database = ["crm_contacts", "crm_companies", "crm_deals", "crm_activities", "crm_notes"]
config = ["app.*"]

[hooks.on-content-created]
priority = 50

[hooks.on-content-updated]
priority = 50

[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline"
handler = "getPipeline"

[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline/:dealId"
handler = "getDealDetail"

[[routes]]
method = "POST"
path = "/api/v1/plugins/crm/deals/:dealId/stage"
handler = "updateDealStage"

main.js(节选):

import { dbQuery, dbExec, ok, fail, extractJson, logInfo, eventEmit, newId } from 'sdk';

export function getPipeline() {
    const stages = ["prospecting", "qualification", "proposal", "negotiation", "closed_won", "closed_lost"];
    const pipeline = [];
    for (const stage of stages) {
        const rows = dbQuery(
            `SELECT id, title, amount FROM crm_deals WHERE stage = ? ORDER BY amount DESC`,
            [stage]
        );
        pipeline.push({ stage, deals: rows || [] });
    }
    return ok({ stages: pipeline });
}

export function getDealDetail(input) {
    const dealId = extractJson(input, "params.dealId");
    if (!dealId) return fail(400, "deal id required");
    const deals = dbQuery(`SELECT * FROM crm_deals WHERE id = ?`, [dealId]);
    if (!deals || deals.length === 0) return fail(404, "deal not found");
    return ok(deals[0]);
}

export function on_content_created(input) {
    const data = extractJson(input, "body");
    if (data.content_type === "contact") {
        logInfo(`[crm] new contact: ${data.id}`);
        eventEmit("crm.lead_created", JSON.stringify({ contact_id: data.id }));
    }
    return ok(data);
}