mirror of
https://github.com/RaisFast/raisfast.git
synced 2026-09-23 16:02:25 +00:00
21 KiB
21 KiB
rquickjs 动态脚本插件方案
在现有 WASM 插件系统基础上,新增 QuickJS (rquickjs) 作为第二运行时, 支持 JavaScript/TypeScript 编写插件,降低社区贡献门槛。
1. 背景
1.1 现状
当前插件系统基于 wasmtime,插件用 Rust 编写后编译为 WASM。
痛点:
- 插件开发者必须安装 Rust 工具链 + wasm32-unknown-unknown target
- ABI 协议复杂(手动内存管理、长度前缀协议)
- 调试困难(WASM 黑盒)
- 编译-部署流程繁琐(cargo build → copy .wasm → 重启)
1.2 目标
- 支持用 JS/TS 编写插件,零 Rust 工具链依赖
- 与现有 WASM 插件共享 Hook 调度机制
- 插件加载/卸载/热重载与 WASM 一致
- 安全隔离(内存限制、执行超时)
- 编译体积影响最小
1.3 选型对比
| 维度 | wasmtime (现有) | deno_core (V8) | rquickjs (本方案) |
|---|---|---|---|
| 引擎体积 (release) | ~15MB | ~12MB + 40MB V8 | ~2MB |
| 编译时间增量 | 基准 | +4 min | +30 sec |
| 依赖 crate 数 | ~280 | ~30 | ~5 |
| 冷启动 | ~5-50ms | ~10-100ms | ~1-5ms |
| 内存/实例 | ~1-2MB | ~2-5MB | ~200-500KB |
| Send/Sync | 是 | 否(需独立线程) | 否(AsyncRuntime 内置) |
| API 稳定性 | 稳定 (v26) | 不稳定 (0.398) | 较稳定 (0.11) |
| 文档覆盖率 | 优秀 | 31% | 100% |
| JS 性能 | N/A | JIT(最快) | 解释执行(博客场景够用) |
选择 rquickjs 的核心理由:
- 集成成本最低 —
AsyncRuntime直接在 tokio 中使用,无需独立线程 - 体积最小 — QuickJS 编译为 ~1MB 静态库,CI 几乎无影响
- 博客插件不需要 JIT — 每个 Hook 执行几行 JS,解释执行完全够用
- API 稳定、文档完整 — 0.11 版本,100% 文档覆盖
2. 架构设计
2.1 模块结构
src/plugins/
├── mod.rs # PluginManager(统一入口,按 runtime 字段分派)
├── manifest.rs # 复用现有 manifest(runtime 字段区分引擎)
├── engine.rs # WASM 引擎(现有,不改)
├── host.rs # WASM host functions(现有,不改)
├── engine_js.rs # QuickJS 引擎(新增)
└── js_host.rs # JS 宿主函数(新增)
2.2 类型体系
// mod.rs — 插件实例枚举
enum LoadedPluginInstance {
Wasm(RwLock<WasmInstance>),
Js(JsPluginRef),
}
struct LoadedPlugin {
manifest: PluginManifest,
instance: LoadedPluginInstance,
}
struct JsPluginRef {
plugin_id: String,
// 通过 Arc<JsEngine> 共享引擎,异步调用
}
2.3 PluginManager 分派
impl PluginManager {
pub async fn dispatch_filter<T>(&self, hook: HookPoint, input: T) -> AppResult<T> {
let plugins = self.plugins.read().await;
let mut sorted: Vec<_> = plugins.values().collect();
sorted.sort_by_key(/* priority */);
let mut current = input;
for plugin in sorted {
let func_name = hook.wasm_func_name();
if !plugin.manifest.hooks.contains_key(func_name) { continue; }
match &plugin.instance {
LoadedPluginInstance::Wasm(wasm) => {
let mut inst = wasm.write().await;
current = wasm_call_filter(&mut inst, func_name, current)?;
}
LoadedPluginInstance::Js(js) => {
current = self.js_engine
.call_filter(&js.plugin_id, func_name, ¤t)
.await?;
}
}
}
Ok(current)
}
}
2.4 线程模型
┌─────────────────────────────────────────┐
│ tokio runtime │
│ │
│ PluginManager (Arc) │
│ ├── wasm_engine: wasmtime::Engine │
│ ├── js_engine: Arc<JsEngine> │
│ │ ├── runtime: AsyncRuntime (Mutex) │
│ │ └── contexts: HashMap<AsyncContext> │
│ └── plugins: RwLock<HashMap> │
│ │
│ axum handler │
│ └── dispatch_filter() │
│ ├── .await (tokio task) │
│ └── ctx.with(|ctx| { ... }).await │
│ │
└─────────────────────────────────────────┘
AsyncRuntime和AsyncContext使用 tokio async Mutex- 每个插件一个
AsyncContext(独立全局作用域) - 所有 JS 调用通过
ctx.with(|ctx| { ... }).await执行 - 不需要独立线程(对比 deno_core)
3. 引擎实现
3.1 JsEngine
// engine_js.rs
use std::collections::HashMap;
use std::sync::Arc;
use rquickjs::{AsyncRuntime, AsyncContext, CatchResultExt};
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::sync::Mutex;
pub struct JsEngine {
runtime: AsyncRuntime,
contexts: Mutex<HashMap<String, AsyncContext>>,
memory_limit: usize,
timeout_ms: u64,
}
impl JsEngine {
pub fn new(memory_limit_mb: u32, timeout_ms: u64) -> anyhow::Result<Self> {
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit((memory_limit_mb as usize) * 1024 * 1024);
runtime.set_max_stack_size(512 * 1024);
// TODO: set_interrupt_handler for timeout
Ok(Self {
runtime,
contexts: Mutex::new(HashMap::new()),
memory_limit: (memory_limit_mb as usize) * 1024 * 1024,
timeout_ms,
})
}
/// 加载 JS 插件代码
pub async fn load_plugin(&self, id: &str, code: &str) -> anyhow::Result<()> {
let ctx = AsyncContext::full(&self.runtime)?;
ctx.with(|ctx| {
ctx.eval(code)?;
Ok::<_, rquickjs::Error>(())
}).await?;
self.contexts.lock().await.insert(id.to_string(), ctx);
Ok(())
}
/// 卸载插件
pub async fn unload_plugin(&self, id: &str) {
self.contexts.lock().await.remove(id);
}
/// 调用 Filter Hook(返回修改后的数据)
pub async fn call_filter<T: Serialize + DeserializeOwned>(
&self,
plugin_id: &str,
func_name: &str,
input: &T,
) -> anyhow::Result<T> {
let contexts = self.contexts.lock().await;
let ctx = contexts.get(plugin_id)
.ok_or_else(|| anyhow::anyhow!("js plugin not found: {plugin_id}"))?;
let input_json = serde_json::to_value(input)?;
let result = ctx.with(|ctx| {
let global = ctx.globals();
let plugin_obj: rquickjs::Object = global.get("Plugin")?;
let func: rquickjs::Function = plugin_obj.get(func_name)?;
let input_val = rquickjs::String::from_json(&ctx, &input_json.to_string())?;
let result_val = func.call1(input_val)?;
let result_str: String = result_val.get()?;
let output: T = serde_json::from_str(&result_str)?;
Ok(output)
}).await?;
Ok(result)
}
/// 调用 Action Hook(无返回值)
pub async fn call_action<T: Serialize>(
&self,
plugin_id: &str,
func_name: &str,
data: &T,
) -> anyhow::Result<()> {
let contexts = self.contexts.lock().await;
let ctx = contexts.get(plugin_id)
.ok_or_else(|| anyhow::anyhow!("js plugin not found: {plugin_id}"))?;
let data_json = serde_json::to_value(data)?;
ctx.with(|ctx| {
let global = ctx.globals();
let plugin_obj: rquickjs::Object = global.get("Plugin")?;
let func: rquickjs::Function = plugin_obj.get(func_name)?;
let val = rquickjs::String::from_json(&ctx, &data_json.to_string())?;
func.call1(val)?;
Ok::<_, rquickjs::Error>(())
}).await?;
Ok(())
}
/// 调用 String Filter Hook(如 render_markdown、filter_html)
pub async fn call_string_filter(
&self,
plugin_id: &str,
func_name: &str,
input: &str,
) -> anyhow::Result<Option<String>> {
let contexts = self.contexts.lock().await;
let ctx = contexts.get(plugin_id)
.ok_or_else(|| anyhow::anyhow!("js plugin not found: {plugin_id}"))?;
let result = ctx.with(|ctx| {
let global = ctx.globals();
let plugin_obj: rquickjs::Object = global.get("Plugin")?;
let func: rquickjs::Function = plugin_obj.get(func_name)?;
let result_val: rquickjs::String = func.call1((input,))?;
let output: String = result_val.get()?;
Ok(output)
}).await?;
Ok(Some(result))
}
}
3.2 宿主函数
// js_host.rs
/// 注册到 JS 全局对象的宿主函数
///
/// 插件可直接调用:
/// Host.log("info", "hello from plugin");
/// var cfg = Host.getConfig("seo.keywords");
fn register_host_functions(ctx: &rquickjs::Ctx) -> anyhow::Result<()> {
let global = ctx.globals();
let host = rquickjs::Object::new(ctx)?;
host.set("log", rquickjs::Function::new(ctx, |ctx: rquickjs::Ctx, level: String, msg: String| {
match level.as_str() {
"warn" => tracing::warn!("[plugin] {msg}"),
"error" => tracing::error!("[plugin] {msg}"),
_ => tracing::info!("[plugin] {msg}"),
}
Ok::<(), rquickjs::Error>(())
})?)?;
host.set("getConfig", rquickjs::Function::new(ctx, |ctx: rquickjs::Ctx, key: String| {
// 从 OpState 读取配置
let value = None::<String>; // TODO: 接入 AppConfig
Ok(value)
})?)?;
global.set("Host", host)?;
Ok(())
}
4. 插件清单
4.1 plugin.toml 变更
# plugins/seo-optimizer/plugin.toml
[plugin]
id = "com.raisfast.seo-optimizer"
name = "SEO Optimizer"
version = "1.0.0"
description = "自动优化文章 SEO"
author = "raisfast"
license = "MIT"
runtime = "js" # "wasm" (默认) 或 "js"
language = "typescript" # 信息字段
entry = "index.js" # JS 入口文件名(默认 index.js)
4.2 manifest.rs 变更
#[derive(Debug, Clone, Deserialize)]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
// ... 现有字段 ...
#[serde(default = "default_runtime")]
pub runtime: String, // "wasm" | "js"
#[serde(default)]
pub language: String,
#[serde(default = "default_wasm")]
pub wasm: String, // WASM 文件名
#[serde(default = "default_entry")]
pub entry: String, // JS 入口文件名
}
fn default_entry() -> String {
"index.js".into()
}
4.3 插件目录结构
plugins/
├── seo-optimizer/ # WASM 插件(现有)
│ ├── plugin.toml
│ └── seo_optimizer.wasm
├── content-filter/ # WASM 插件(现有)
│ ├── plugin.toml
│ └── content_filter.wasm
└── welcome-email/ # JS 插件(新增)
├── plugin.toml
└── index.js
5. JS 插件开发规范
5.1 约定
JS 插件必须导出一个全局 Plugin 对象,包含对应的 Hook 方法。
// 插件全局对象
var Plugin = {
// Hook 方法名与 HookPoint.wasm_func_name() 一致
// Filter Hook — 接收 JSON 字符串,返回修改后的 JSON 字符串
on_post_creating: function(inputJson) {
var input = JSON.parse(inputJson);
if (!input.excerpt) {
input.excerpt = input.content.substring(0, 200) + "...";
}
return JSON.stringify(input);
},
// Action Hook — 接收 JSON 字符串,无返回值
on_post_created: function(dataJson) {
var data = JSON.parse(dataJson);
Host.log("info", "New post published: " + data.title);
},
// String Filter Hook — 接收字符串,返回修改后的字符串
filter_html: function(html) {
return html.replace(
"<head>",
'<head><meta property="og:type" content="article">'
);
}
};
5.2 Hook 方法签名
| Hook 类型 | 方法签名 | 说明 |
|---|---|---|
| JSON Filter | function(inputJson: string): string |
接收/返回 JSON 字符串 |
| JSON Action | function(dataJson: string): void |
接收 JSON 字符串,无返回值 |
| String Filter | function(input: string): string |
接收/返回原始字符串 |
5.3 宿主 API
插件可通过全局 Host 对象调用宿主函数:
| 函数 | 签名 | 说明 |
|---|---|---|
Host.log(level, msg) |
(string, string) -> void |
写入宿主日志 |
Host.getConfig(key) |
`(string) -> string | null` |
5.4 TypeScript 支持
QuickJS 不内置 TypeScript 编译。推荐使用 esbuild 预编译:
# 开发时编译
esbuild plugins/welcome-email/src/index.ts --outfile=plugins/welcome-email/index.js --bundle --format=iife --target=es2021
# 或在 justfile 中
just plugins-js-build
TypeScript 类型定义(随项目提供):
// plugins-sdk-js/index.d.ts
interface Host {
log(level: "info" | "warn" | "error", message: string): void;
getConfig(key: string): string | null;
}
declare var Host: Host;
interface PluginHooks {
on_post_creating?(inputJson: string): string;
on_post_created?(dataJson: string): void;
on_post_updating?(inputJson: string): string;
on_post_updated?(dataJson: string): void;
on_post_deleted?(dataJson: string): void;
on_comment_creating?(inputJson: string): string;
on_comment_created?(dataJson: string): void;
render_markdown?(content: string): string;
filter_html?(html: string): string;
handle_route?(routeJson: string): string;
on_login?(dataJson: string): void;
}
declare var Plugin: PluginHooks;
6. 示例插件
6.1 Welcome Email(JS)
# plugins/welcome-email/plugin.toml
[plugin]
id = "com.raisfast.welcome-email"
name = "Welcome Email"
version = "1.0.0"
description = "用户注册后发送欢迎邮件"
runtime = "js"
language = "javascript"
[hooks.on-login]
priority = 20
// plugins/welcome-email/index.js
var Plugin = {
on_login: function(dataJson) {
var data = JSON.parse(dataJson);
if (data.success) {
Host.log("info", "User logged in: " + data.email);
}
}
};
6.2 SEO Optimizer(JS 版本,替代 WASM 版本)
# plugins/seo-optimizer-js/plugin.toml
[plugin]
id = "com.raisfast.seo-optimizer-js"
name = "SEO Optimizer (JS)"
version = "1.0.0"
description = "自动优化文章 SEO"
runtime = "js"
language = "typescript"
entry = "index.js"
[permissions]
max_memory_mb = 8
timeout_ms = 2000
[hooks.on-post-creating]
priority = 10
[hooks.filter-html]
priority = 5
// plugins/seo-optimizer-js/src/index.ts → 编译为 index.js
var Plugin = {
on_post_creating: function(inputJson: string): string {
var input = JSON.parse(inputJson);
if (!input.excerpt || input.excerpt === "") {
var plain = input.content
.replace(/```[\s\S]*?```/g, "")
.replace(/[#*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
input.excerpt = plain.substring(0, 200);
if (plain.length > 200) input.excerpt += "...";
}
return JSON.stringify(input);
},
filter_html: function(html: string): string {
var meta = '<meta property="og:type" content="article">';
return html.replace("<head>", "<head>" + meta);
}
};
7. Feature Flag 与编译
7.1 Cargo.toml
[features]
default = ["db-sqlite"]
# Database backend
db-sqlite = ["sqlx/sqlite"]
db-postgres = ["sqlx/postgres"]
db-mysql = ["sqlx/mysql"]
# Plugin runtimes
plugin-wasm = ["wasmtime"]
plugin-js = ["rquickjs"]
plugin-all = ["plugin-wasm", "plugin-js"]
[dependencies]
# ... 现有依赖 ...
# Plugin: WASM (可选)
wasmtime = { version = "26", optional = true }
wasmtime-wasi = { version = "26", optional = true }
# Plugin: QuickJS (可选)
rquickjs = { version = "0.11", features = ["futures", "loader", "macro"], optional = true }
7.2 编译示例
# 仅 WASM 插件(现有行为)
cargo build --features "db-sqlite,plugin-wasm"
# 仅 JS 插件
cargo build --features "db-sqlite,plugin-js"
# 双引擎(推荐)
cargo build --features "db-sqlite,plugin-all"
7.3 条件编译
// src/plugins/mod.rs
#[cfg(feature = "plugin-wasm")]
mod engine;
#[cfg(feature = "plugin-js")]
mod engine_js;
#[cfg(feature = "plugin-js")]
mod js_host;
// PluginManager 根据 feature 编译不同的分派逻辑
8. 安全机制
8.1 内存限制
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit(32 * 1024 * 1024); // 32MB 默认
可在 plugin.toml 的 [permissions] 中配置 max_memory_mb。
8.2 执行超时
runtime.set_interrupt_handler(Some(Box::new(move || {
// 检查是否超过 timeout_ms
start_time.elapsed().as_millis() > timeout_ms as u128
})));
8.3 沙箱隔离
- 每个 JS 插件运行在独立
AsyncContext(独立全局作用域) - 无文件系统访问(不注册
std模块) - 无网络访问(不注册
fetch等) - 只能通过
Host全局对象与宿主交互
8.4 与 WASM 安全对比
| 安全维度 | WASM (wasmtime) | QuickJS (rquickjs) |
|---|---|---|
| 内存隔离 | 独立线性内存 | 同进程内存(QuickJS GC 管理) |
| 计算限制 | fuel(精确指令计数) | interrupt handler(周期检查) |
| 文件系统 | 默认无 | 默认无 |
| 网络访问 | 默认无 | 默认无 |
| 逃逸风险 | 极低 | 低(依赖 QuickJS 安全更新) |
对于博客系统,QuickJS 的安全级别足够。
9. justfile 新增命令
# ── 插件 ──────────────────────────────────────────────────────────
# 编译所有 WASM 插件
plugins-build:
@echo "Building seo-optimizer..."
cd plugins-examples/seo-optimizer && cargo build --target wasm32-unknown-unknown --release
@echo "Building content-filter..."
cd plugins-examples/content-filter && cargo build --target wasm32-unknown-unknown --release
@mkdir -p plugins/seo-optimizer plugins/content-filter
cp plugins-examples/seo-optimizer/target/wasm32-unknown-unknown/release/seo_optimizer.wasm plugins/seo-optimizer/
cp plugins-examples/seo-optimizer/plugin.toml plugins/seo-optimizer/
cp plugins-examples/content-filter/target/wasm32-unknown-unknown/release/content_filter.wasm plugins/content-filter/
cp plugins-examples/content-filter/plugin.toml plugins/content-filter/
@echo "Done. WASM plugins ready in plugins/"
# 编译所有 JS 插件(TypeScript → JavaScript)
plugins-js-build:
@echo "Building JS plugins..."
@for dir in plugins-examples-js/*/; do \
if [ -f "$$dir/src/index.ts" ]; then \
name=$$(basename $$dir); \
echo " Compiling $$name..."; \
npx esbuild "$$dir/src/index.ts" --outfile="plugins/$$name/index.js" --bundle --format=iife --target=es2021; \
cp "$$dir/plugin.toml" "plugins/$$name/"; \
fi \
done
@echo "Done. JS plugins ready in plugins/"
# 编译所有插件(WASM + JS)
plugins-all: plugins-build plugins-js-build
10. 实施计划
Phase 1:最小集成(1-2 天)
Cargo.toml添加rquickjs可选依赖- 创建
engine_js.rs(JsEngine 基本结构) - 修改
manifest.rs(新增entry字段) - 修改
mod.rs(按runtime字段分派) - 创建
js_host.rs(Host.log) - 编写欢迎邮件示例插件
- 条件编译验证(
plugin-js/plugin-all)
Phase 2:功能完善(1-2 天)
dispatch_filter/dispatch_action/dispatch_render_overrideJS 分派- 安全机制(内存限制、超时)
js_host.rs完善(Host.getConfig)- 热重载支持(复用现有文件监听)
- justfile 命令
Phase 3:开发者体验(1-2 天)
- TypeScript 类型定义文件
- esbuild 编译配置
- JS 版 SEO Optimizer 示例插件
- 插件开发文档
Phase 4:测试(1 天)
- JsEngine 单元测试
- 集成测试(JS 插件 Hook 调用)
- 双引擎共存测试
- 安全机制测试(内存限制、超时)
11. 风险与缓解
| 风险 | 严重性 | 缓解措施 |
|---|---|---|
| QuickJS 不支持部分 ES 特性 | 低 | esbuild target=es2021 polyfill |
#![deny(unsafe_code)] 冲突 |
中 | QuickJS 在 rquickjs crate 内部封装,不侵入主 crate |
| rquickjs 版本升级 | 低 | 锁定版本,feature flag 可禁用 |
| JS 插件安全性弱于 WASM | 低 | 博客场景可接受,内存限制 + 超时 |
| JSON 序列化开销 | 低 | 博客 Hook 数据量小(< 100KB) |