12 KiB
Plugin ES Module + SDK 设计方案
1. 目标
将 JS/Lua 插件从全局脚本模式升级为模块化模式,引入版本化 SDK,让插件开发体验更现代、更安全。
1.1 核心改进
- JS:
import/export原生 ESM 语法,引擎通过 rquickjs Module Loader 自动收集 export 函数 - Lua:
require("sdk")模块加载,通过 Rust 侧Host.jsonEncode/Decode避免 Lua 端手写 JSON 解析器 - SDK 命名体系:统一
namespaceAction风格(dbQuery、httpGet、configGet、storeSet、vfsRead、logInfo、eventEmit) - 响应模型:
ok(data)直接返回数据,框架统一包装为{code:0, data};fail(status, msg)返回错误标记 - 路由参数:引擎侧
extract_route_params()提取命名参数注入input.params,插件通过extractJson(input, "params.dealId")安全获取 - ID 生成:
Host.newId()调用 Rust 侧Uuid::now_v7(),与系统其他部分一致 - JSON 提取:
extractJson(input, "params.id")支持点号路径,链路中任何一级不存在安全返回null - 并发模型:JS/Lua 采用 per-request 模式(每次调用新建 VM),WASM 采用实例池
2. SDK 分发方式
SDK 是框架的一部分,编译进 Rust 二进制文件,不依赖外部文件。
2.1 文件结构
plugin-sdk/
js/
js_plugin_v1.js ← JS SDK v1 源码
js_plugin_v1.ts ← TypeScript 源码(编译用)
tsconfig.json ← TypeScript 配置
lua/
lua_plugin_v1.lua ← Lua SDK v1 源码
src/plugins/
sdk_v1.rs ← include_str! 常量
2.2 嵌入方式
// src/plugins/sdk_v1.rs
pub const JS_SDK_V1: &str = include_str!("../../plugin-sdk/js/js_plugin_v1.js");
pub const JS_SDK_V1_VERSION: &str = "1.0.0";
pub const LUA_SDK_V1: &str = include_str!("../../plugin-sdk/lua/lua_plugin_v1.lua");
pub const LUA_SDK_V1_VERSION: &str = "1.0.0";
2.3 版本路由
pub fn get_sdk_source(runtime: &str, version: &str) -> Option<&'static str> {
match (runtime, version) {
("js", "v1") => Some(JS_SDK_V1),
("lua", "v1") => Some(LUA_SDK_V1),
_ => None,
}
}
3. JS 架构
3.1 per-request 模式
JS 引擎采用方案 D(per-request):每次调用创建全新 QuickJS context,用完销毁。零状态泄漏,无需锁竞争,完美隔离。
PluginManager.call_filter("my-plugin", "on_content_creating", input)
→ JsEngine.call_filter()
→ new AsyncContext from pre-compiled module
→ extract function from Plugin object
→ call function(input) with interrupt handler + outer timeout
→ drop context (释放所有内存)
3.2 Module Loader
自定义 rquickjs::Loader,处理两种标识符:
| 标识符 | 解析规则 | 示例 |
|---|---|---|
"sdk" |
返回嵌入的 JS SDK 源码 | import { dbQuery } from 'sdk' |
"./xxx.js" / "../xxx.js" |
相对于插件目录(canonicalize 防路径穿越) | import { helper } from './utils.js' |
3.3 Export → Plugin 对象桥接
compile_module("main.js", code) + eval()
↓
module.namespace() → 遍历 keys → 收集 Function → 注册到 Plugin 对象
↓
框架通过 Plugin.on_xxx(input) 调用
3.4 JS 返回值处理
JS 函数直接返回 JS 对象(不再 JSON.stringify),引擎通过 ctx.json_stringify() 在 C 层序列化为 serde_json::Value,省去 JS 端字符串分配和 GC 压力。
3.5 Host 函数注册
Host 函数注册到 PLUGIN_HOST_GLOBAL("RaisFastHost")全局对象:
global.set(PLUGIN_HOST_GLOBAL, host)?;
20 个 Host 函数(详见 plugin-dev-guide.md Host API 章节)。
3.6 内存与超时
runtime.set_memory_limit(max_memory_bytes); // 按 permissions.max_memory_mb
runtime.run_gc(); // 每次调用后强制 GC
set_interrupt_handler(|| is_timed_out()); // 同步中断
tokio::time::timeout(duration, ...); // 异步外层超时
4. Lua 架构
4.1 per-request 模式
Lua 引擎同样采用方案 D(per-request):每次调用创建全新 Lua VM,用完销毁。
4.2 模块加载
Lua 通过 Rust 侧注册的自定义 require 函数加载模块:
"sdk"→ 执行嵌入的 Lua SDK 源码,通过_sdk_module全局变量返回 table"./xxx"→ 读取插件目录下的文件并执行
4.3 Lua SDK 依赖 Host.jsonEncode/Decode
Lua 沙箱环境不含 json 库。SDK 内部通过 Rust 侧暴露的 Host.jsonEncode(val) / Host.jsonDecode(str) 进行 JSON 序列化,避免在 Lua 端手写解析器。
4.4 Lua Export → Plugin 对象
Lua 没有 JS 的 module namespace 概念。Lua 插件仍使用全局 Plugin table:
local sdk = require("sdk")
Plugin = {}
Plugin.on_content_creating = function(input) ... end
4.5 Host 函数
Lua Host 注册 22 个函数(比 JS 多 jsonEncode / jsonDecode),同样注册到 PLUGIN_HOST_GLOBAL 全局表。
5. WASM 架构
5.1 实例池模式
WASM 引擎采用实例池模式:启动时预编译 N 个 wasmtime 实例,通过 Semaphore + round-robin 分发。
pub struct WasmInstancePool {
instances: Vec<PooledInstance>,
semaphore: Semaphore, // 限制并发数 = pool_size
}
5.2 Host 函数
WASM Host 通过 WIT 包 raisfast:plugin-wit 定义接口,使用 snake_case 命名(db_query、vfs_read)。
WASM Host 暴露 19 个函数,不包含 new_uuid(JS/Lua 的 newId 在 WASM 中不可用)。
6. SDK v1 API 设计
JS 和 Lua SDK 提供完全相同的 API(28 个函数),只是语法不同。
6.1 数据库(5 个)
| 函数 | 说明 |
|---|---|
dbQuery(sql, params?) |
参数化 SELECT;错误时抛异常 |
dbExec(sql, params?) |
INSERT/UPDATE/DELETE,返回 {error?, rows_affected} |
dbBegin() |
开启事务(失败时抛异常) |
dbCommit() |
提交事务(失败时抛异常) |
dbRollback() |
回滚事务 |
6.2 HTTP(4 个)
| 函数 | 说明 |
|---|---|
httpGet(url) |
GET 请求,返回原始字符串 |
httpGetJson(url) |
GET 请求,自动解析 JSON |
httpPost(url, body) |
POST 请求,返回原始字符串 |
httpPostJson(url, body) |
POST 请求,自动解析 JSON |
6.3 配置与存储(3 个)
| 函数 | 说明 |
|---|---|
configGet(key) |
读取配置 |
storeGet(key) |
读取 KV 存储 |
storeSet(key, value) |
写入 KV 存储 |
6.4 虚拟文件系统(6 个)
| 函数 | 说明 |
|---|---|
vfsRead(path) |
读取 VFS 文件 |
vfsWrite(path, content) |
写入 VFS 文件 |
vfsDelete(path) |
删除文件 |
vfsExists(path) |
检查存在 |
vfsList(path) |
列出目录 |
vfsStat(path) |
获取文件信息(size, is_dir, modified) |
6.5 内容查询(1 个)
| 函数 | 说明 |
|---|---|
getPost(slug) |
按 slug 获取文章,返回 JSON 对象 |
6.6 响应工具(3 个)
| 函数 | 说明 |
|---|---|
ok(data) |
成功响应:返回数据,框架包装为 {code:0, data} |
fail(status, msg) |
错误响应:框架包装为 {code:N, message} |
extractJson(input, field?) |
从 JSON 提取字段(支持 params.id 点号路径),不存在返回 null |
6.7 通用工具(6 个)
| 函数 | 说明 |
|---|---|
logInfo(msg) / logWarn(msg) / logError(msg) |
日志输出 |
newId() |
生成 UUID v7(时间排序,与系统一致) |
eventEmit(type, data) |
发射事件 |
7. Manifest
[plugin]
id = "com.example.my-plugin"
name = "My Plugin"
version = "1.0.0"
runtime = "js" # "js" / "lua" / "wasm"
entry = "main.js" # JS: main.js Lua: init.lua WASM: plugin.wasm
sdk_version = "v1" # 可选,默认 "v1"
[permissions]
max_memory_mb = 16
timeout_ms = 5000
database = ["products"]
config = ["app.*"]
[dependencies]
"com.raisfast.auth" = ">=1.0.0"
[hooks.on-content-creating]
priority = 50
match = "product"
content_types = ["product"]
[[cron]]
label = "每日统计"
job_type = "daily_stats"
cron_expr = "0 0 * * *"
[[routes]]
method = "GET"
path = "/api/v1/plugins/my-plugin/stats"
handler = "getStats"
auth = "public"
[[routes.input]]
name = "page"
type = "integer"
in = "query"
default = 1
[[routes.output.fields]]
name = "total"
type = "integer"
[[content_types]]
file = "content_types/contact.toml"
[[admin_pages]]
path = "/admin/plugins/my-plugin"
label = "My Plugin"
icon = "puzzle"
8. 插件编写对比
8.1 JS
import { dbQuery, dbExec, ok, fail, extractJson, logInfo, newId } from 'sdk';
export function on_content_creating(input) {
const data = extractJson(input, "body");
if (data?.title) data.title = data.title.toUpperCase();
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]);
return ok(rows[0]);
}
8.2 Lua
local sdk = require("sdk")
Plugin = {}
Plugin.on_content_creating = function(input)
local data = sdk.extractJson(input, "body")
if data and data.title then
data.title = string.upper(data.title)
end
return sdk.ok(data)
end
Plugin.getProduct = function(input)
local id = sdk.extractJson(input, "params.id")
if not id then return sdk.fail(400, "id required") end
local rows = sdk.dbQuery("SELECT * FROM products WHERE id = ?", { id })
return sdk.ok(rows[1])
end
9. 路由参数
引擎侧在 dispatch_route 中通过 extract_route_params() 提取命名参数,注入 input.params:
path: /api/v1/plugins/crm/pipeline/deal-123
pattern: /api/v1/plugins/crm/pipeline/:dealId
→ input.params = {"dealId": "deal-123"}
插件通过 extractJson(input, "params.dealId") 安全获取,点号路径中任何一级不存在返回 null。
10. 响应处理
框架在 call_plugin_json 中统一处理:
- 检查返回值是否包含
__plugin_error: true→ 返回{code: status*100, message, data: null} - 否则直接包装为
{code: 0, message: "success", data: result}
11. 向后兼容
不兼容。新格式不向后兼容。
- JS:必须使用
export function(不再支持var Plugin = {}) - Lua:仍使用
Plugin.xxxfunction,但工具函数从 SDK 导入 Host全局对象仍存在(SDK 内部使用),不推荐插件直接调用
12. 风险与注意事项
12.1 QuickJS ESM 限制
import在eval_module中同步执行- 不支持
import.meta.url - 不支持动态
import()
12.2 Lua 限制
- 沙箱不含
package标准库,自定义require函数替代 - 相对路径模块需要约定
_sdk_module全局变量返回 SDK table - 无原生模块隔离,所有
require的模块共享全局作用域
12.3 安全
- SDK 不可被插件覆盖(Loader/require 优先匹配
"sdk") - 相对路径限制在插件目录内(canonicalize + starts_with 防路径穿越)
- Host API 权限校验不变
- 全局对象名
PLUGIN_HOST_GLOBAL = "RaisFastHost",不使用Host避免与用户代码冲突
12.4 JS/Lua SDK API 一致性
- 两个 SDK 提供完全相同的 28 个 API 名称和行为
- 差异仅在于语言特性(JS
nullvs Luanil,JS 数组 vs Lua 1-indexed table)
12.5 三运行时 Host 函数差异
- JS/Lua 用 camelCase(
dbQuery、vfsRead) - WASM 用 snake_case(
db_query、vfs_read) - WASM 不暴露
newId/new_uuid - Lua 额外暴露
jsonEncode/jsonDecode(无原生 JSON 支持)