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

610 lines
20 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 开发指南
## 概述
Plugin 系统是 rust-blog 的运行时扩展机制,支持三种语言运行时,可独立于 Content Type 运行。Plugin 可以注册钩子、定时任务、自定义路由,并通过 Host API 访问数据库、HTTP、配置等受控资源。
## 架构
```
plugins/
└── {plugin-id}/
├── manifest.toml # 插件清单
└── main.js # 入口文件(JS/Lua/WASM
↓ 启动加载
PluginManagerArc 共享)
├─ 拓扑排序(依赖顺序)
├─ 实例池(round-robin 并发)
└─ 热重载(文件系统监听)
↓ Hook 派发
Host API(沙箱权限控制)
├─ Host.dbQuery / dbExecute
├─ Host.dbBegin / dbCommit / dbRollback
├─ Host.httpGet / httpPost
├─ Host.getConfig
├─ Host.getData / setDataKV 存储)
├─ Host.fsRead / fsWrite / fsDelete / fsExists / fsListVFS
├─ 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`)
### 最小示例
```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]` 权限声明
```toml
[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]` 钩子注册
```toml
[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]]` 定时任务
```toml
[[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]]` 自定义路由
```toml
[[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.*` 接口访问宿主功能:
### 数据库
```javascript
// 参数化查询(防注入)
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
```javascript
const html = Host.httpGet("https://api.example.com/data");
const result = Host.httpPost("https://api.example.com/webhook", JSON.stringify({event: "test"}));
```
### KV 存储
```javascript
Host.setData("last_sync", "2026-01-01");
const val = Host.getData("last_sync");
```
每个插件有独立命名空间,互不干扰。
### 配置
```javascript
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
```javascript
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}/` 下有隔离沙箱,路径不能包含 `..`
### 其他
```javascript
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
```
### 代码模板
```javascript
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`** 导出 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`
### 相对路径导入
```javascript
import { helper } from './utils.js';
```
## 编写 Lua 插件(SDK 模式)
### 项目结构
```
plugins/my-plugin/
├── manifest.toml
└── init.lua
```
### 代码模板
```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 命令
```bash
# 创建新插件
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**
```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(节选):**
```javascript
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);
}
```