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

12 KiB
Raw Blame History

Plugin ES Module + SDK 设计方案

1. 目标

将 JS/Lua 插件从全局脚本模式升级为模块化模式,引入版本化 SDK,让插件开发体验更现代、更安全。

1.1 核心改进

  • JSimport/export 原生 ESM 语法,引擎通过 rquickjs Module Loader 自动收集 export 函数
  • Luarequire("sdk") 模块加载,通过 Rust 侧 Host.jsonEncode/Decode 避免 Lua 端手写 JSON 解析器
  • SDK 命名体系:统一 namespaceAction 风格(dbQueryhttpGetconfigGetstoreSetvfsReadlogInfoeventEmit
  • 响应模型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 引擎采用方案 Dper-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 引擎同样采用方案 Dper-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_queryvfs_read)。

WASM Host 暴露 19 个函数,不包含 new_uuidJS/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 HTTP4 个)

函数 说明
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 中统一处理:

  1. 检查返回值是否包含 __plugin_error: true → 返回 {code: status*100, message, data: null}
  2. 否则直接包装为 {code: 0, message: "success", data: result}

11. 向后兼容

不兼容。新格式不向后兼容。

  • JS:必须使用 export function(不再支持 var Plugin = {}
  • Lua:仍使用 Plugin.xxx function,但工具函数从 SDK 导入
  • Host 全局对象仍存在(SDK 内部使用),不推荐插件直接调用

12. 风险与注意事项

12.1 QuickJS ESM 限制

  • importeval_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 null vs Lua nilJS 数组 vs Lua 1-indexed table

12.5 三运行时 Host 函数差异

  • JS/Lua 用 camelCasedbQueryvfsRead
  • WASM 用 snake_casedb_queryvfs_read
  • WASM 不暴露 newId/new_uuid
  • Lua 额外暴露 jsonEncode/jsonDecode(无原生 JSON 支持)