Files
raisfast/docs/plugin-dev-guide.md
T
2026-05-07 13:09:37 +08:00

25 KiB
Raw Blame History

Plugin 开发指南

概述

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

架构

plugins/
  └── {plugin-id}/
       ├── manifest.toml    # 插件清单
       └── main.js          # 入口文件(JS/Lua/WASM
              ↓ 启动加载
PluginManagerArc 共享)
  ├─ 拓扑排序(依赖顺序)
  ├─ JS/Lua: per-request(每次调用新建 VM,用完销毁)
  ├─ WASM: 实例池(round-robin 并发)
  └─ 热重载(文件系统监听)
              ↓ Hook 派发
Host API(沙箱权限控制,全局对象名: RaisFastHost
  ├─ Host.dbQuery / Host.dbExecute
  ├─ Host.dbBegin / Host.dbCommit / Host.dbRollback
  ├─ Host.httpGet / Host.httpPost
  ├─ Host.getConfig
  ├─ Host.getData / Host.setDataKV 存储)
  ├─ Host.getPost(获取文章)
  ├─ Host.vfsRead / Host.vfsWrite / Host.vfsDelete / Host.vfsExists / Host.vfsList / Host.vfsStat
  ├─ Host.log / Host.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 per-request 运行时
JS Host src/plugins/js_host.rs JS → Rust Host API 桥接
Lua Engine src/plugins/engine_lua.rs Lua 5.4 per-request 运行时
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 请求
SDK v1 src/plugins/sdk_v1.rs SDK 版本管理 + include_str! 嵌入
CLI src/cli/plugin_cmd.rs plugin new / plugin check

SDK 分发

SDK 编译进 Rust 二进制文件(include_str!),不依赖外部文件:

plugin-sdk/
  js/
    js_plugin_v1.js     ← JS SDK v1 源码
    js_plugin_v1.ts     ← TypeScript 源码(编译用)
  lua/
    lua_plugin_v1.lua   ← Lua SDK v1 源码

三种运行时

运行时 Cargo Feature 入口文件 引擎 并发模型
JavaScript plugin-js main.js rquickjs (QuickJS) per-request
Lua plugin-lua init.lua mlua (Lua 5.4) per-request
WASM plugin-wasm plugin.wasm wasmtime 实例池

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

并发模型差异

模型 适用 原理 隔离性
per-request JS、Lua 每次调用创建全新 VM,用完销毁 完美隔离,零状态泄漏
实例池 WASM N 个预编译实例,round-robin 分发 实例间隔离,实例内需注意状态

JS/Lua 的 PLUGIN_JS_POOL_SIZE / PLUGIN_LUA_POOL_SIZE 配置项保留但当前 per-request 模式不使用。

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 文件路径
sdk_version string "v1" SDK 版本

[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
match = "product"           # 仅匹配 content_type
content_types = ["product"] # 仅匹配指定 content type

[hooks.render-markdown]
priority = 10
字段 类型 默认 说明
priority int 100 优先级,数字越小越先执行
match string 匹配规则(Content Type 名称)
content_types string[] [] 仅匹配指定的 Content Type

钩子名使用连字符(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 类型:仅副作用,返回值忽略。

[dependencies] 插件依赖

[dependencies]
"com.raisfast.auth" = ">=1.0.0"
"com.raisfast.analytics" = ">=2.0.0"

系统启动时按拓扑排序加载,确保依赖先于当前插件初始化。

[[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"
description = "获取联系人详情"
字段 类型 必填 默认 说明
method string HTTP 方法
path string 路由路径,支持 :param 占位符
handler string 对应 Plugin 对象的函数名
auth string default none / public / member / admin
description string 描述
permission string 额外权限要求

路由参数定义(input

[[routes]]
method = "POST"
path = "/api/v1/plugins/crm/deals"
handler = "createDeal"

[[routes.input]]
name = "title"
type = "string"
in = "body"
required = true
description = "交易标题"

[[routes.input]]
name = "page"
type = "integer"
in = "query"
default = 1
description = "页码"

路由输出定义(output

[routes.output]
description = "交易列表"

[[routes.output.fields]]
name = "id"
type = "string"
description = "交易 ID"

[[routes.output.fields]]
name = "title"
type = "string"
description = "交易标题"

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

[[content_types]] Content Type 文件引用

[[content_types]]
file = "content_types/contact.toml"

插件可以自带 Content Type TOML 文件,安装时自动加载。

[[admin_pages]] 管理后台页面

[[admin_pages]]
path = "/admin/plugins/crm"
label = "CRM 管理"
icon = "users"
component = "CrmDashboard"
字段 类型 必填 说明
path string 页面路径
label string 显示名称
icon string 图标
component string 前端组件名

Host API

所有运行时通过统一的 RaisFastHost 全局对象访问宿主功能(由 PLUGIN_HOST_GLOBAL 常量定义):

数据库

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

内容查询

const postJson = Host.getPost("my-post-slug");

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, plugin.max_memory_mb, plugin.default_timeout_ms

文件系统(VFS

Host.vfsWrite("/reports/daily.json", reportJson);
const data = Host.vfsRead("/reports/daily.json");
const exists = Host.vfsExists("/reports/daily.json");
const files = Host.vfsList("/reports");
Host.vfsDelete("/reports/old.json");
const stat = Host.vfsStat("/reports/daily.json");  // → {"size":1024,"is_dir":false,"modified":1234567890}

每个插件在 {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(时间排序,与系统主键一致)

WASM 运行时注意Host.newId() 仅在 JS/Lua 运行时可用,WASM 运行时未暴露此函数。

编写 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 APIimport { ... } 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) 列出目录下文件,返回数组
vfsStat(path) 获取文件信息(size, is_dir, modified
getPost(slug) 按 slug 获取文章,返回 JSON 对象

关键约定

  • 必须使用 export function 导出 handlerES 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
  • SDK 不可被插件覆盖(Module Loader 优先匹配 "sdk"
  • Host 全局对象仍存在(SDK 内部使用),不推荐插件直接调用

相对路径导入

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 APIlocal 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) 列出目录下文件,返回数组
sdk.vfsStat(path) 获取文件信息(size, is_dir, modified
sdk.getPost(slug) 按 slug 获取文章

关键约定

  • 必须导出全局 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) 转换
  • Lua SDK 额外提供 Host.jsonEncode(val) / Host.jsonDecode(str) 用于 JSON 序列化(Lua 沙箱无原生 JSON 支持)

Host 函数对比(三运行时)

函数 JS Lua WASM 备注
log
getConfig / get_config WASM 用 snake_case
httpGet / http_get
httpPost / http_post
getData / get_data
setData / set_data
getPost / get_post
dbQuery / db_query
dbExecute / db_execute
dbBegin / db_begin
dbCommit / db_commit
dbRollback / db_rollback
vfsRead / vfs_read
vfsWrite / vfs_write
vfsDelete / vfs_delete
vfsExists / vfs_exists
vfsList / vfs_list
vfsStat / vfs_stat
newId / new_uuid WASM 未暴露
emitEvent / emit_event
jsonEncode Lua 专用(无原生 JSON
jsonDecode Lua 专用(无原生 JSON

JS/Lua 用 camelCaseWASM 用 snake_case。

错误恢复

  • 连续 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 命令

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

# 校验插件
raisfast plugin check                      # 校验默认目录
raisfast 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 配置(当前 per-request 不使用)
PLUGIN_LUA_POOL_SIZE 4 Lua 配置(当前 per-request 不使用)

完整示例:CRM 插件

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

manifest.toml

[plugin]
id = "com.raisfast.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);
}