# 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 的核心理由:** 1. 集成成本最低 — `AsyncRuntime` 直接在 tokio 中使用,无需独立线程 2. 体积最小 — QuickJS 编译为 ~1MB 静态库,CI 几乎无影响 3. 博客插件不需要 JIT — 每个 Hook 执行几行 JS,解释执行完全够用 4. 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 类型体系 ```rust // mod.rs — 插件实例枚举 enum LoadedPluginInstance { Wasm(RwLock), Js(JsPluginRef), } struct LoadedPlugin { manifest: PluginManifest, instance: LoadedPluginInstance, } struct JsPluginRef { plugin_id: String, // 通过 Arc 共享引擎,异步调用 } ``` ### 2.3 PluginManager 分派 ```rust impl PluginManager { pub async fn dispatch_filter(&self, hook: HookPoint, input: T) -> AppResult { 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 │ │ │ ├── runtime: AsyncRuntime (Mutex) │ │ │ └── contexts: HashMap │ │ └── plugins: RwLock │ │ │ │ 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 ```rust // 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>, memory_limit: usize, timeout_ms: u64, } impl JsEngine { pub fn new(memory_limit_mb: u32, timeout_ms: u64) -> anyhow::Result { 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( &self, plugin_id: &str, func_name: &str, input: &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 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( &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> { 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 宿主函数 ```rust // 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::; // TODO: 接入 AppConfig Ok(value) })?)?; global.set("Host", host)?; Ok(()) } ``` --- ## 4. 插件清单 ### 4.1 plugin.toml 变更 ```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 变更 ```rust #[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 方法。 ```javascript // 插件全局对象 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( "", '' ); } }; ``` ### 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 预编译: ```bash # 开发时编译 esbuild plugins/welcome-email/src/index.ts --outfile=plugins/welcome-email/index.js --bundle --format=iife --target=es2021 # 或在 justfile 中 just plugins-js-build ``` TypeScript 类型定义(随项目提供): ```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) ```toml # 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 ``` ```javascript // 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 版本) ```toml # 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 ``` ```typescript // 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 = ''; return html.replace("", "" + meta); } }; ``` --- ## 7. Feature Flag 与编译 ### 7.1 Cargo.toml ```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 编译示例 ```bash # 仅 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 条件编译 ```rust // 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 内存限制 ```rust let runtime = AsyncRuntime::new()?; runtime.set_memory_limit(32 * 1024 * 1024); // 32MB 默认 ``` 可在 `plugin.toml` 的 `[permissions]` 中配置 `max_memory_mb`。 ### 8.2 执行超时 ```rust 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 新增命令 ```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_override` JS 分派 - [ ] 安全机制(内存限制、超时) - [ ] `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) |