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

403 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 嵌入方式
```rust
// 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 版本路由
```rust
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 对象桥接
```text
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"`)全局对象:
```rust
global.set(PLUGIN_HOST_GLOBAL, host)?;
```
20 个 Host 函数(详见 plugin-dev-guide.md Host API 章节)。
### 3.6 内存与超时
```rust
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
```lua
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 分发。
```rust
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 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
```toml
[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
```javascript
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
```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`
```text
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 限制
- `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 `null` vs Lua `nil`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 支持)