插件系统设计
本文档基于以下源文件编写:
- internal/jsruntime/runtime.go -- QuickJS 运行时包装与事件循环
- internal/jsruntime/polyfill.go -- JS 标准 API polyfill
- internal/jsruntime/pendingjob.go -- QuickJS 原生微任务执行
- internal/jsplugin/manager.go -- 插件管理器(协调器)
- internal/jsplugin/plugin.go -- 插件模型与 Manifest 定义
- internal/jsplugin/service.go -- per-plugin 服务实例
- internal/jsplugin/scheduler.go -- 消息调度器
目录
1. 插件系统概览
章节来源:internal/jsruntime/runtime.go、internal/jsplugin/manager.go
Songloft 的 JS 插件系统允许第三方开发者在沙盒环境中扩展服务器功能(音源接入、Web UI 页面、跨插件通信等)。选择 QuickJS 的核心考量:
- 沙盒安全:每个插件拥有完全隔离的 VM 实例(堆内存、全局对象),一切外部能力必须通过显式注册的桥接函数获取。
- CGO-free:通过
modernc.org/quickjs(纯 Go 翻译自 C 源码)实现,无需 CGO,保证全平台交叉编译零额外依赖。 - 确定性执行:单线程引擎,所有 JS 执行通过
sync.Mutex串行化,不存在竞态条件。
整体架构分为三层:
HTTP 请求
|
+--------v--------+
| Manager | 协调器:生命周期、懒加载、热更新
+--------+--------+
|
+--------v--------+
| ServiceScheduler| Skynet 风格消息队列,per-service 单 worker
+--------+--------+
|
+--------v--------+
| JSService | per-plugin Actor:消息路由 + JS 执行
+--------+--------+
|
+--------v--------+
| JSEnvManager | 运行时管理:VM 创建/销毁/执行/事件循环
+--------+--------+
|
+--------v--------+
| QuickJS VM | 沙盒:polyfill + 插件代码 + 桥接函数
+---+---------+---+
| |
__go_fetch __go_bridge
(HTTP) (storage/DB/IPC)2. QuickJS 运行时
章节来源:internal/jsruntime/runtime.go
2.1 核心结构体
图表来源:runtime.go:92-117(JSEnv 结构体字段定义)
JSEnvManager 管理进程内的所有 JS 环境,JSEnv 代表一个独立的 VM 实例:
JSEnvManager
envs: map[string]*JSEnv // envID -> 环境实例
pluginEnvs: map[int64]map[string] // pluginID -> 关联的 envID 集合
shutdownCh: chan struct{} // 全局关闭信号
JSEnv
vm: *quickjs.VM // QuickJS 虚拟机(非线程安全)
mu: sync.Mutex // 串行化所有 VM 访问
asyncResults: chan(256) // 异步结果通道(fetch/bridge 完成后投递)
asyncSignal: chan(1) // 单容量信号通道,唤醒事件循环
asyncInflight: atomic.Int32 // 飞行中异步任务计数
wsConns: sync.Map // WebSocket 连接池
bridgeCallback: BridgeCallback // 插件层桥接回调2.2 环境创建
CreateEnv 创建新环境:quickjs.NewVM() → SetMaxStackSize → registerBridgeFunctions → 注入 polyfill → 执行初始化代码 → 记录归属关系。CreateEnvWithBytecode 是变体路径,先执行 bootstrap 源码再加载预编译字节码(.jsc),用于重启后从缓存加载。
2.3 执行模型
ExecuteJS 实现完整的异步事件循环:
快速路径:eval(code) 返回值非 thenable 且无飞行中异步任务,直接返回(健康探针、定时器等内部调用走此路径)。
慢速路径(异步 Promise):
- 将 Promise 挂到
globalThis.__execjs_pending,附加.then链回填结果 - 事件循环:
pumpAsyncResults→ExecutePendingJobs→processExpiredTimers→ 检查 done flag - 未完成时释放 env.mu,
select等待 asyncSignal / 50ms tick / ctx.Done / shutdownCh - 被唤醒后重新加锁继续
关键保证:异步 await 期间 env.mu 被释放,健康探针、定时器、同插件其他请求均可抢锁,不会因一个 30s 的 fetch 冻结整个插件。
2.4 健康探针与并行执行
HealthProbe 通过 TryLock 直接探测 VM(不经过 scheduler 队列),返回 Healthy/Unhealthy/Busy/Missing 四种状态。ExecuteJSParallel 支持多环境竞速执行(多音源搜索场景),按窗口分批启动 goroutine,第一个成功结果立即返回。
3. Polyfill 与 PendingJob 系统
章节来源:internal/jsruntime/polyfill.go、internal/jsruntime/pendingjob.go
3.1 Polyfill 清单
QuickJS 只提供 ES2023 语言规范。polyfillJS 在每个 VM 创建时注入,补齐标准接口:
| 分类 | Polyfill | 实现方式 |
|---|---|---|
| 控制台 | console.log/error/warn/info/debug/trace | 委托 __go_console 输出到 Go slog |
| 定时器 | setTimeout/clearTimeout/setInterval/clearInterval | 纯 JS Map + __go_now_ms(),Go 侧周期调用 __processExpiredTimers |
| 网络 | fetch(url, opts) | 原生 Promise + __go_fetch_async 后台 goroutine;支持内部头 X-Fetch-No-Redirect / X-Fetch-Timeout-Ms |
| 编码 | TextEncoder/TextDecoder、btoa/atob | __go_buffer_from/to_string + 纯 JS |
| 二进制 | Buffer.from/alloc/concat/isBuffer | hex 内部表示 + Go 桥接 |
| 加密 | crypto.md5/sha1/sha256Bytes/rc4/aesEncrypt/aesDecrypt/rsaEncrypt/randomBytes | Go 标准库 crypto/* |
| 压缩 | zlib.inflate/deflate | Go compress/zlib |
| URL | URL/URLSearchParams | 纯 JS 正则解析 |
| WebSocket | WebSocket (connect/send/close/events) | __go_ws_* + gorilla/websocket |
Polyfill 还包含 Function.prototype.toString 重写,将桥接函数标记为 [native code],兼容 jsjiami.com v7 混淆脚本的反调试检查。
3.2 异步回调注册表
__asyncCallbacks(Map<id, {resolve, reject}>)是 JS 侧异步基础设施核心:fetch/bridge 创建 Promise 时存入 resolve/reject,Go goroutine 完成后推到 env.asyncResults,事件循环调用 __pumpAsyncResults → __resolveAsync 根据 type 包装 payload 并 resolve。WebSocket 消息通过 __wsRegistry 直接分发。
3.3 PendingJob 与定时器
pendingjob.go 通过 unsafe/reflect 访问 VM 内部字段,调用 JS_ExecutePendingJob 推进原生 Promise 微任务。SetMaxStackSize 将栈大小设为 0(使用默认 MaxStackSlots=1000),防止混淆代码递归反调试。
Go 侧定时器处理有三条路径:ExecuteJS 事件循环内的 processExpiredTimers(50ms tick);JSService.runTimerProcessor 独立 goroutine(500ms 周期,TryLock 非阻塞);processJobs 完整循环(异步结果 + 微任务 + 定时器,连续 500ms 无进展则退出)。
4. 宿主桥接架构
章节来源:internal/jsruntime/runtime.go:1326-1720
Go 与 JS 通过 vm.RegisterFunc 注册的全局函数通信。耗时操作遵循真异步模式:JS 调用立即返回 ID,后台 goroutine 执行,通过 asyncResults 通道回送结果。
4.1 桥接函数清单
| 函数 | 用途 | 模式 |
|---|---|---|
__go_send / __go_console | 事件派发 / 日志 | 同步 |
__go_fetch_async | HTTP 请求;内部消费并剥离 X-Fetch-No-Redirect / X-Fetch-Timeout-Ms | 真异步("fetch:N") |
__go_bridge | 通用桥接(storage/DB/IPC) | 真异步("bridge:N") |
__go_pop_async_result | 弹出就绪异步结果 | 同步(非阻塞) |
__go_now_ms / __go_buffer_* / __go_crypto_* / __go_zlib_* | 工具函数 | 同步 |
__go_ws_connect_async / __go_ws_send/close/state | WebSocket | 连接异步,操作同步 |
4.2 异步结果流转
JS: fetch(url) → new Promise → __asyncCallbacks.set(id, {resolve, reject})
→ __go_fetch_async(url, ...) → 返回 id, VM 锁释放
Go: goroutine doHTTPRequest → env.asyncResults <- result → env.asyncSignal
JS: 事件循环加锁 → __pumpAsyncResults → __resolveAsync(id, ok, data, "fetch")
→ cb.resolve(Response{...}) → ExecutePendingJobs → await 链继续__go_bridge 的实际处理由 BridgeHandler.HandleBridgeCall 完成,将 action 分发到 Go 服务(storage/歌曲/歌单/跨插件通信/子进程等),是插件访问宿主能力的唯一入口。
5. 消息调度器
章节来源:internal/jsplugin/scheduler.go
ServiceScheduler 借鉴 Skynet Actor 模型,为每个插件提供 worker goroutine + 消息队列(cap=256):
| 消息类型 | 说明 |
|---|---|
MsgHTTPRequest | HTTP 路由请求 |
MsgInterPlugin | 插件间通信 |
MsgLifecycle | 生命周期(init/deinit) |
MsgHealthCheck | 健康检查 |
- Send -- 异步投递,不等响应
- Call -- 同步调用,
RespChan+ 超时(默认 30s) - dispatch -- 非阻塞投递,队列满返回
ErrQueueFull
Worker 串行处理消息,保证同一插件请求不并发。处理前检查 msg.Ctx.Err():客户端已放弃的请求直接跳过,避免 worker 被过时请求卡住。
6. Manager 概览
章节来源:internal/jsplugin/manager.go
Manager 是插件系统顶层协调器,持有 Repository(SQLite 持久化)、PackageManager(ZIP 安装/同步)、ServiceScheduler、JSEnvManager、HealthChecker、HotReloader 和 singleflight.Group(懒加载去重)。
6.1 启动流程
Start(ctx) 依次执行:SyncPluginsFromDirectory(ZIP → DB 同步)→ loadPlugins(加载所有 active 插件)→ RefreshPublicPaths → 启动 HealthChecker → 启动 HotReloader。
6.2 核心操作
| 方法 | 行为 |
|---|---|
LoadPlugin | 创建 JSService + BridgeHandler → service.Load(hash 校验/JS 环境创建)→ scheduler 注册 → onInit() |
UnloadPlugin | scheduler 注销 → service.Stop() |
ReloadPlugin | Unload + 清除字节码缓存 + Load |
EnablePlugin | DB 状态 active + Load,清空自愈退避 |
DisablePlugin | Unload + DB 状态 inactive |
6.3 懒加载(EnsureLoaded)
请求到达时目标插件未加载(可能因空闲驱逐),EnsureLoaded 按需加载。使用 singleflight.Group 按 entryPath 去重:50 个并发只执行一次 LoadPlugin。DB 中 inactive 返回 403,error 返回 503(HealthChecker 指数退避自愈),不存在返回 404。
6.4 关闭流程
Close() 保证无死锁:jsManager.SignalShutdown() → 停止 HealthChecker → 取消 context → 遍历 service 注销/Stop → 关闭 scheduler → 关闭 jsManager(tryLockWithTimeout 3s 兜底)。
7. Plugin 模型
章节来源:internal/jsplugin/plugin.go、internal/models/models.go:519-543
7.1 JSPlugin 结构体
定义在 models 包,jsplugin 包通过类型别名引用。关键字段:
| 字段 | 说明 |
|---|---|
EntryPath | 路由前缀(^[a-z][a-z0-9-]*$) |
Main | 入口文件(.js/.jsc) |
Permissions | 权限列表(net/storage/fs:music 等) |
PublicPaths | 无需 JWT 的路径前缀 |
ExternalPaths | 可访问的外部绝对路径 |
ZipHash / EntryHash | 双层完整性校验 hash |
Status | 状态:active/inactive/error |
7.2 Manifest 与校验
plugin.json 由 @songloft/plugin-builder 打包生成。ValidateManifest 校验:name 2-50 字符、version semver、entryPath 小写字母开头、main 以 .js/.jsc 结尾、entryHash/zipHash 为 64 位小写 hex。
7.3 状态机
安装/启用 加载失败 / 健康检查异常
--------> active -----------------------> error
^ |
| HealthChecker 指数退避自愈 |
+--------------------------------+
|
用户禁用 v
--------> inactive8. Service 实例
章节来源:internal/jsplugin/service.go
8.1 JSService 结构
Per-plugin Actor,实现 MessageHandler 接口。持有插件元数据、envID、scheduler、jsManager、BridgeHandler,维护 status(ready/running/frozen/stopped)和 lastActive(空闲驱逐判定)。
8.2 加载流程(Load)
双层 hash 校验 + 代码加载:
- 读取 ZIP → Layer 1 计算规范化 ZIP hash(排除 plugin.json),mtime 未变但 hash 不一致判定篡改
- 读取入口文件 → Layer 2 计算 SHA256 校验一致性
- 字节码缓存:ZIP 自带
.jsc直接用;有效缓存加载;否则用源码并异步编译缓存 - 创建 JS 环境(
GetBootstrapCode()+ 插件代码)→ 注册 BridgeCallback → 解压 static/bin 目录
8.3 生命周期
Load() -> Init() -> [HandleMessage 循环] -> Deinit() -> Stop()| 回调 | 超时 | 说明 |
|---|---|---|
onInit() | 10s | 插件初始化(注册路由/启动定时任务) |
onDeinit() | 10s | 插件清理(关闭连接/保存状态) |
Init() 完成后启动 runTimerProcessor goroutine(500ms 周期 TryLock 处理 JS 定时器)。
8.4 请求处理
HandleMessage 按消息类型分发:
- MsgHTTPRequest:将请求序列化为 JSON,包装成
(async function(){return JSON.stringify(await onHTTPRequest(req));})()交给ExecuteJS。支持 base64 body 编码和msg.Ctx取消。result == ""返回 502(handler 漏 return),ctx.Canceled返回 499。 - MsgInterPlugin:调用
__handleInterPluginMessage(jsonStr)处理插件间通信 - MsgHealthCheck:执行
1+1,断言结果为 2 - MsgLifecycle:分发 init/deinit 回调
8.5 停止流程
Stop() 的执行顺序:
- 关闭
timerStopchannel,停止定时器 goroutine - 调用
Deinit()(忽略错误,保证后续清理继续) bridgeHandler.Cleanup()(终止后台子进程)jsManager.DestroyPluginEnvs(pluginID)-- 批量销毁所有关联 JS 环境(包括主 env 和子 env)
