插件开发框架
本文档基于以下源文件编写:
- internal/jsplugin/api_bridge.go -- 宿主 API 桥接(bootstrap JS + BridgeHandler 分发)
- internal/jsplugin/api_bridge_command.go -- 命令执行桥接(exec/start/stop/download)
- internal/jsplugin/api_bridge_fs.go -- 文件系统桥接(music:// 协议、外部路径)
- internal/jsplugin/routes.go -- 路由注册(静态文件、API 转发、publicPaths、公共资源)
- internal/jsplugin/permissions.go -- 权限模型(声明层白名单 + 运行时校验)
- internal/jsplugin/communication.go -- 插件间通信(send/call + onMessage handler)
- internal/jsplugin/invoke.go -- 内部 HTTP 调用(InvokeHTTP 绕过路由直达插件)
- internal/jsplugin/service.go -- 请求处理生命周期(JSService Actor 模型)
- internal/jsplugin/manager.go -- 插件管理器(加载/卸载/懒加载/热重载)
- internal/jsplugin/plugin.go -- PluginManifest 结构与校验
- internal/jsplugin/scheduler.go -- 消息调度器(Skynet 风格的消息分发)
- internal/jsplugin/assets/common.css -- MD3 颜色系统与组件样式
- internal/jsplugin/assets/common.js -- embed 检测 + 主题桥接 + SongloftPlugin 全局 API
- internal/jsruntime/polyfill.go -- fetch polyfill(真异步 HTTP)
- internal/jsruntime/runtime.go -- QuickJS 运行时管理
目录
- 架构总览
- API Bridge 总览
- HTTP API 桥接 -- fetch
- Storage API
- Logger API
- Songs / Playlists / Plugin API
- JSEnv API -- 子 JS 环境
- Command 执行桥接
- 文件系统桥接
- 路由注册系统
- 权限模型
- 公共资源注入
- 插件间通信
- 嵌入模式
- 请求处理生命周期
1. 架构总览
章节来源: internal/jsplugin/api_bridge.go, internal/jsplugin/service.go, internal/jsplugin/manager.go
Songloft 的 JS 插件系统运行在 QuickJS 沙盒中,每个插件拥有独立的 JS 虚拟机实例。插件通过宿主提供的 songloft 全局对象与 Go 后端交互,所有宿主 API 统一返回 Promise,插件代码必须使用 await 调用。
核心分层:
+---------------------------+
| 插件 JS 代码 (QuickJS) | ← 开发者编写
+---------------------------+
| songloft.* API 层 | ← pluginBootstrapJS 注入
+---------------------------+
| __callBridge 传输层 | ← polyfill 提供
+---------------------------+
| BridgeHandler 分发器 | ← api_bridge.go
+---------------------------+
| Go 服务层 | ← database/services/os
+---------------------------+每个 API 调用在 JS 侧通过 __callBridge(action, data) 发起,桥接层将 action 字符串(如 "storage.get"、"songs.list")路由到 Go 侧的 BridgeHandler.HandleBridgeCall,根据 action 前缀分发到对应的处理器方法。
插件生命周期回调:
onInit()-- 插件加载完成后调用,可在此注册消息处理器、初始化状态onHTTPRequest(req)-- 接收 HTTP 请求,返回{statusCode, headers, body}响应onWebSocket(req, socket)-- 接收入站 WebSocket upgrade(需websocket权限),注册消息/关闭/错误回调onDeinit()-- 插件卸载前调用,清理资源
三个回调均必须通过 globalThis.xxx = ... 赋值覆盖默认实现,不能使用 function 声明(QuickJS Annex B 块级函数声明会创建 declarative binding,导致覆盖失败)。
2. API Bridge 总览
章节来源: internal/jsplugin/api_bridge.go 中的 pluginBootstrapJS 常量及 HandleBridgeCall 方法
宿主通过 pluginBootstrapJS 在每个插件 VM 启动时注入 songloft 全局对象。以下是完整的 API 命名空间与方法清单:
| 命名空间 | 方法 | 所需权限 | 说明 |
|---|---|---|---|
songloft.log | info(msg), warn(msg), error(msg) | 无 | 同步日志,输出到 slog |
songloft.storage | get(key), set(key, value), delete(key), keys() | storage | KV 存储,按 entryPath 隔离 |
songloft.persistentStorage | get(key), set(key, value), delete(key), keys() | persistent-storage | 持久化 KV 存储,插件卸载后仍保留 |
songloft.songs | list(options), getById(id), search(query) | songs.read | 歌曲数据库查询 |
songloft.playlists | list(), getById(id), getSongs(id, options) | playlists.read | 歌单数据库查询 |
songloft.plugin | getToken(), getHostUrl(), getFileUrl(path) | 无(内置能力) | 插件自身信息与宿主 URL |
songloft.jsenv | create(), execute(), executeWait(), executeParallel(), destroy(), list() | jsenv | 子 JS 环境管理 |
songloft.command | exec(), start(), stop(), isRunning(), download(), deleteBin(), listBin(), exists() | command | 外部命令执行与二进制管理 |
songloft.fs | readFile(), writeFile(), appendFile(), readdir(), unlink(), exists(), mkdir(), stat(), rename() | fs / fs:music / fs:external | 文件系统操作 |
songloft.comm | send(), call(), onMessage() | inter-plugin | 插件间通信 |
songloft.net | udpBind(), udpSend(), udpJoinMulticast(), udpLeaveMulticast(), udpGetLocalAddr(), udpClose(), tcpConnect() | net | 原始网络 socket(UDP/TCP,出站连接 + 消息推送) |
fetch() | -- | 无(runtime polyfill) | 标准 fetch API,真异步 HTTP;支持内部控制头 |
权限检查流程:HandleBridgeCall 入口处先从 action 提取所需权限(extractPermFromAction),再与插件 manifest 声明的权限列表比对。plugin.* 操作是内置能力,跳过权限检查;fs.* 操作的权限按路径类型在 resolveFSPath 内细粒度检查。
3. HTTP API 桥接 -- fetch
章节来源: internal/jsruntime/polyfill.go, internal/jsruntime/runtime.go
插件中的 fetch() 函数是标准 Web API 的 polyfill 实现,采用真异步设计:
JS 调 fetch(url, opts)
→ new Promise → 注册到 __asyncCallbacks
→ 调 __go_fetch_async(url, method, headers, body)
→ Go goroutine 并行执行 HTTP 请求
→ 完成后通过 asyncResults channel 投递结果
→ 事件循环 pump 阶段 resolve Promise
→ .then() 回调在下一轮微任务执行设计动机:QuickJS 是单线程 VM,如果 fetch 同步阻塞,会卡住整个 env 的消息队列。真异步方案让 Go 侧 goroutine 并行跑 HTTP,JS 侧立即返回 Promise,VM 锁在此期间被释放,不影响其他操作(如健康检查探针)。
返回的 Response 对象支持 .text()、.json() 等标准方法。每个异步调用通过 "fetch:N" 格式的 ID 与 JS 侧的 __asyncCallbacks 注册表关联,__resolveAsync 根据 type 前缀决定如何包装 payload(fetch 类型包装成 Response 对象)。
fetch 支持两个运行时内部请求头:X-Fetch-No-Redirect 禁止 Go HTTP 客户端自动跟随重定向,便于插件自行处理登录跳转链;X-Fetch-Timeout-Ms 将单次请求超时设置为 100-30000ms 范围内的值。这两个头只在运行时内部消费,不会转发给目标服务器。
4. Storage API
章节来源: internal/jsplugin/api_bridge.go 中的 handleStorage 方法
按插件隔离的持久化 KV 存储,数据存放在 data/jsplugins_data/{entryPath}/data/ 目录,每个 key 对应一个文件。value 在 JS 侧自动 JSON.stringify/JSON.parse。
await songloft.storage.set("config", { theme: "dark" });
const config = await songloft.storage.get("config"); // 返回对象或 null
const keys = await songloft.storage.keys();
await songloft.storage.delete("config");安全限制:key 不能为空、不能包含 /、\、..(防目录遍历);存储目录自动按需创建。
5. Logger API
章节来源: internal/jsplugin/api_bridge.go 中的 songloft.log 定义
唯一的同步 API(不返回 Promise),映射到 QuickJS console 方法,最终通过 __go_console 桥接输出到 Go slog,带 [plugin] 前缀。
songloft.log.info("插件启动完成"); // → console.log('[plugin] ...')
songloft.log.warn("配置缺失");
songloft.log.error("加载失败");6. Songs / Playlists / Plugin API
章节来源: internal/jsplugin/api_bridge.go 中的 handleSongs、handlePlaylists、handlePlugin 方法
Songs & Playlists(只读数据库查询)
const songs = await songloft.songs.list({ pathPrefix: "/music/", limit: 20, offset: 0 });
const song = await songloft.songs.getById(42);
const results = await songloft.songs.search({ query: "周杰伦", limit: 20 });
const playlists = await songloft.playlists.list();
const playlist = await songloft.playlists.getById(1);
const songs = await songloft.playlists.getSongs(1, { limit: 100, offset: 0 });权限映射:songs.* 操作需要 songs.read;playlists.* 操作需要 playlists.read。可在 manifest 中声明 songs.* 或 playlists.* 通配符获取全部读写权限。
Plugin(内置能力,无需权限)
const token = await songloft.plugin.getToken(); // 永久 JWT Token
const hostUrl = await songloft.plugin.getHostUrl(); // "http://localhost:58091"
const url = await songloft.plugin.getFileUrl("data/report.pdf");
// → "/api/v1/jsplugin/{entryPath}/files/data/report.pdf?access_token=..."getFileUrl 拼接的路径通过 /files/* 路由由 Go 原生 http.ServeFile 直接服务,支持 Range 请求和 HTTP 缓存。
7. JSEnv API -- 子 JS 环境
章节来源: internal/jsplugin/api_bridge.go 中的 handleJSEnv 方法
允许在独立 QuickJS VM 中创建隔离子环境,用于运行用户脚本或多 worker 并行协作。需要 jsenv 权限。
const envName = await songloft.jsenv.create("worker1", "var counter = 0;");
const result = await songloft.jsenv.execute("worker1", "counter++; counter", 30000);
// result: { result: "1", events: [] }
// 并行执行(竞速模式,首个成功即返回)
const parallel = await songloft.jsenv.executeParallel([
{ name: "worker1", code: "...", timeoutMs: 5000 },
{ name: "worker2", code: "...", timeoutMs: 5000 }
], 2);
await songloft.jsenv.destroy("worker1");命名空间隔离:JS 侧的 name 在桥接端自动拼接为 {rootEnvID}::{name},DestroyPluginEnvs(pluginID) 在插件卸载时自动回收所有子环境。name 不能包含 :: 或 /。
8. Command 执行桥接
章节来源: internal/jsplugin/api_bridge_command.go
Command API 允许执行外部命令和管理二进制文件,需要 command 权限。
一次性执行与后台进程
// 一次性执行(默认 60s 超时,最大 300s)
const result = await songloft.command.exec("ffmpeg", ["-i", "in.mp3", "out.wav"], {
timeout: 60000, stdin: "", env: { PATH: "/usr/bin" }
});
// result: { exitCode: 0, stdout: "...", stderr: "..." }
// 后台进程管理
const { pid } = await songloft.command.start("server", "my-server", ["--port", "8080"]);
const running = await songloft.command.isRunning("server");
await songloft.command.stop("server");二进制文件管理
await songloft.command.download("https://example.com/tool.tar.gz", "tool.tar.gz", {
extract: "tgz", extractTarget: "tool"
});
const exists = await songloft.command.exists("ffmpeg");
const bins = await songloft.command.listBin();
await songloft.command.deleteBin("old-tool");安全限制:程序名解析优先级为插件 bin/ 目录 > 系统 PATH;文件名校验 ^[a-zA-Z0-9][a-zA-Z0-9._-]*$;stdout/stderr 各限 10MB;下载限 500MB;ELF 解释器缺失时自动提示(glibc/musl 不兼容);插件卸载时 Cleanup() 自动终止所有后台进程。
9. 文件系统桥接
章节来源: internal/jsplugin/api_bridge_fs.go
FS API 提供文件系统操作,权限按路径类型细粒度检查。
三种路径形式
| 路径格式 | 解析规则 | 所需权限 |
|---|---|---|
"relative/path" | {pluginDataDir}/{entryPath}/relative/path | fs |
"music://xxx" | {music_path}/xxx | fs:music |
"/absolute/path" | 绝对路径,须在 manifest externalPaths 声明的目录内 | fs:external |
方法清单
const text = await songloft.fs.readFile("config.json");
const binary = await songloft.fs.readFile("music://song.mp3", { encoding: "base64" });
await songloft.fs.writeFile("output/result.json", JSON.stringify(data));
await songloft.fs.appendFile("log.txt", "new line\n");
const entries = await songloft.fs.readdir("music://"); // [{name, isDir}]
await songloft.fs.unlink("temp.txt"); // 不能删目录
const exists = await songloft.fs.exists("config.json");
await songloft.fs.mkdir("output/subdir", { recursive: true });
const stat = await songloft.fs.stat("music://song.mp3"); // {size, modTime, isDir}
await songloft.fs.rename("old.txt", "new.txt");安全限制:所有路径禁止包含 ..;filepath.Abs + HasPrefix 确保不逃出基目录;文件大小限 10MB;fs:external 须在 manifest externalPaths 白名单内:
{ "permissions": ["fs:external"], "externalPaths": ["/mnt/nas/music", "/data/shared"] }10. 路由注册系统
章节来源: internal/jsplugin/routes.go
JS 插件的路由分为三层,由 Manager 在启动时注册到 chi Router。
RegisterStaticRoutes(无需认证)
GET /api/v1/jsplugin/{entryPath} → index.html(注入 <base> 和 auth-bridge)
GET /api/v1/jsplugin/{entryPath}/ → 同上
GET /api/v1/jsplugin/{entryPath}/static → 静态目录根(index.html)
GET /api/v1/jsplugin/{entryPath}/static/* → 静态资源文件(CSS/JS/图片)
GET /api/v1/jsplugin-assets/* → 公共资源(common.css/common.js/字体)静态文件服务不依赖插件 JS 运行时是否就绪,只需数据目录中存在 static/ 即可。这确保了插件初始化期间(onInit 尚未完成)前端页面仍可正常加载。
HTML 文件会被注入 <base> 标签、auth-bridge 脚本和公共资源引用后返回,且设置 Cache-Control: no-cache;其他静态资源设置 Cache-Control: public, max-age=31536000, immutable 强缓存。
SPA fallback:未命中的路径会回退到 index.html。
RegisterAPIRoutes(需要认证)
GET/HEAD /api/v1/jsplugin/{entryPath}/files/* → 文件直接 serve(Go 原生 ServeFile)
* /api/v1/jsplugin/{entryPath}/* → catch-all API 转发到 JS 运行时API 转发流程:
- 子路径为空 → 服务
static/index.html - 子路径以
static/开头 → 静态文件兜底 - 其他子路径 → 按需懒加载插件(
EnsureLoaded)→ 构建HTTPRequestData→ 通过scheduler.Call同步调用 JSonHTTPRequest→ 写入 HTTP 响应
ServeFile 指令:JS 可以返回 { serveFile: { songId: 42 } } 或 { serveFile: { filePath: "data/file.bin" } },让 Go 层直接 serve 文件(零拷贝、支持 Range),避免通过 QuickJS 的字符串管道传输二进制数据。
publicPaths 免认证路径
{
"publicPaths": ["/api/callback", "/webhook"]
}插件可在 manifest 中声明 publicPaths,这些子路径被拼接为 /api/v1/jsplugin/{entryPath}{publicPath} 后注册到 AuthMiddleware 的跳过列表。RefreshPublicPaths 在插件安装/卸载/启用/禁用/热重载后被调用,从 DB 重新加载所有插件的 publicPaths 到内存缓存,运行时通过 IsPublicPath(path) 前缀匹配判断是否跳过 JWT 认证。
动态路由挂载
所有路由中的 {entryPath} 均由运行时按已安装插件决定,不是静态注册。chi 的路由优先级机制确保 GET 请求到 /static/* 或 /files/* 路径会优先匹配更具体的路由,不会进入 catch-all。
11. 权限模型
章节来源: internal/jsplugin/permissions.go
权限常量
| 权限标识 | 说明 |
|---|---|
storage | 持久化 KV 存储(按 entryPath 隔离) |
persistent-storage | 持久化 KV 存储,插件卸载后仍保留 |
songs.read | 读取歌曲数据库 |
songs.write | 修改歌曲数据库 |
playlists.read | 读取歌单数据库 |
playlists.write | 修改歌单数据库 |
inter-plugin | 插件间通信(send/call) |
command | 执行外部命令和管理二进制文件 |
jsenv | 创建/执行子 JS 环境 |
fs | 插件数据目录内文件读写 |
fs:music | 访问 music_path 音乐目录 |
fs:external | 访问管理员配置的外部目录 |
websocket | WebSocket 连接(出站 new WebSocket(...) 与入站 onWebSocket) |
net | 原始网络 socket(songloft.net 的 UDP/TCP:出站连接 + 消息推送) |
通配符与声明
声明层支持三个通配符:songs.*、playlists.*、fs.*。CheckPermission 通过前缀匹配实现(如 "playlists.*" 匹配 "playlists.read" 和 "playlists.write";"fs.*" 匹配 "fs"、"fs:music"、"fs:external")。
{ "permissions": ["storage", "songs.read", "fs:music", "command"], "externalPaths": ["/mnt/nas"] }运行时校验流程
HandleBridgeCall入口调用extractPermFromAction(action)将 action(如"songs.list")映射到权限(如"songs.read")CheckPermission(plugin.Permissions, required)比对插件声明的权限列表- 特例:
plugin.*跳过权限检查(内置能力);fs.*在resolveFSPath内按路径类型细粒度检查 - 安装时
ValidatePermissions校验每个值是否在AllPermissions白名单内
12. 公共资源注入
章节来源: internal/jsplugin/routes.go 中的 injectHTMLHead 函数, internal/jsplugin/assets/
每个插件的 HTML 页面在返回给浏览器前,会通过 injectHTMLHead 自动注入四个元素到 <head> 标签紧后方(若无 <head> 则注入到文件开头):
注入顺序:<base> → auth-bridge 脚本 → common.css → common.js<base> 标签
<base href="/api/v1/jsplugin/{entryPath}/">使浏览器以插件路径为基准解析相对路径。必须在所有使用相对 URL 的元素之前注入,否则预加载扫描器会用错误的基准 URL 发起请求。
auth-bridge 脚本
内联脚本完成两个任务:
- 从 URL
?access_token=xxx读取 token 存入localStorage("songloft-auth"),然后通过history.replaceState清理 URL - 包装全局
fetch,对插件 API 路径下的503 plugin_unavailable响应自动等待 200ms 重试一次(配合后端懒加载冷启动)
common.css -- MD3 颜色系统
图表来源: internal/jsplugin/assets/common.css
定义了 Material Design 3 CSS 变量体系(--md-primary、--md-surface、--md-on-surface、--md-error 等),覆盖亮色/暗色双主题(通过 html[data-theme="dark"] 选择器切换)。提供 MD3 组件类:.card、.btn-*、.text-field、.chip、.dialog、.snackbar、.list-item、.switch、.progress-linear、.tab-bar/.tab-item 等。嵌入 Roboto 和 Material Symbols Outlined 字体,强缓存 1 年。
common.js -- 主题桥接与工具 API
图表来源: internal/jsplugin/assets/common.js
三大职责:
- Embed 检测:URL 含
?embed参数时给<html>添加.embedclass,触发 CSS 中的嵌入模式布局 - 主题桥接:
- 初始主题来源优先级:URL
?theme=>localStorage("songloft-theme")>"light" applyTheme(th)设置data-theme属性、切换theme-light/theme-darkclass、持久化到 localStorage、派发songloft-theme-change自定义事件- 监听
postMessage:父窗口发送{type: "songloft-theme", theme: "dark"}时实时切换
- 初始主题来源优先级:URL
window.SongloftPlugin全局 API(供浏览器端 JS 使用,非 QuickJS 环境):getAuthToken()-- 从 localStorage 读取 JWT tokenapiGet(path)/apiPost(path, body)/apiPut(path, body)/apiDelete(path)-- 封装 fetch 请求,自动携带 Authorization 头getTheme()-- 返回当前主题("light"或"dark")onThemeChange(callback)-- 注册主题变化回调
13. 插件间通信
章节来源: internal/jsplugin/communication.go, internal/jsplugin/api_bridge.go 中的 handleComm
插件间通信通过 songloft.comm 命名空间实现,需要 inter-plugin 权限。
发送方 API
// fire-and-forget 异步发送(不等待响应)
await songloft.comm.send("target-plugin", "refresh", { key: "value" });
// 同步调用并等待响应(默认 10s 超时)
const response = await songloft.comm.call("target-plugin", "getData", { id: 42 }, 5000);
// response: { success: true, data: {...} }接收方注册
// 注册消息处理器(handler 可返回值或 Promise)
songloft.comm.onMessage("refresh", async function(payload, from) {
console.log("收到来自", from, "的刷新请求");
return { updated: true };
});内部实现
Communicator 持有 ServiceScheduler 引用:
Send()构建MsgInterPlugin类型的消息,通过scheduler.Send()投递到目标插件的消息队列,不等待响应Call()通过scheduler.Call()同步等待目标插件处理完成并返回InterPluginResponse
JS 侧的 __handleInterPluginMessage(msgJSON) 是内部入口函数,由 Go 侧 ExecuteJS 调用。它解析消息 JSON,查找 songloft.comm._handlers 中注册的对应 action 处理器,执行后将结果包装为 {success, data, error} 返回。handler 可以是 async function,框架会自动 await。
14. 嵌入模式
章节来源: internal/jsplugin/assets/common.css(embed 相关样式), internal/jsplugin/assets/common.js(embed 检测), internal/jsplugin/routes.go(auth-bridge + 主题参数传递)
插件可以作为 tab 内嵌到 Songloft 主应用中,通过 WebView(移动端)或 iframe(Web 端)渲染。
触发机制
主应用在加载插件页面时添加 URL 参数:
/api/v1/jsplugin/{entryPath}?embed&theme=dark&access_token=xxxembed-- 触发嵌入模式布局theme-- 传递当前主题access_token-- 传递认证 token(auth-bridge 脚本自动存入 localStorage 并清理 URL)
布局变化
common.js 检测到 ?embed 参数后给 <html> 添加 .embed class,common.css 中的 html.embed 规则覆盖默认布局:
| 元素 | 独立模式 | 嵌入模式 |
|---|---|---|
.app-bar | 固定顶部 56px | 隐藏 (display: none) |
.tab-bar | 底部 64px(大屏左侧 80px rail) | 顶部 48px 水平 tab bar |
.tab-item | 纵向图标+文字 | 横向图标+文字,14px 字号 |
.tab-content | padding-top: 72px | padding-top: 56px |
大屏 margin-left | 80-100px(给侧边 rail 让位) | 0(无侧边 rail) |
主题实时同步
主应用在运行时切换主题时,通过 postMessage 通知 iframe/WebView:
iframe.contentWindow.postMessage({ type: "songloft-theme", theme: "dark" }, "*");common.js 监听 message 事件,收到后调用 applyTheme() 切换 CSS 变量和 class,实现无刷新主题同步。
15. 请求处理生命周期
章节来源: internal/jsplugin/service.go, internal/jsplugin/manager.go, internal/jsplugin/invoke.go
外部 HTTP 请求处理流程
浏览器请求 → chi Router → handlePluginAPIRequest
→ EnsureLoaded(entryPath) ← 按需懒加载(singleflight 去重)
→ forwardToJSRuntime
→ 构建 HTTPRequestData ← body 非 UTF-8 或 multipart 时 base64 编码
→ scheduler.Call(MsgHTTPRequest)
→ worker → JSService.HandleMessage → handleHTTPRequest
→ jsManager.ExecuteJS(30s) → onHTTPRequest Promise resolve
→ 写 HTTP 响应内部调用(InvokeHTTP)
章节来源: internal/jsplugin/invoke.go
Manager.InvokeHTTP 提供服务端业务逻辑(如 SourceFetcher)直接调用插件 handler 的通道,绕过 chi 路由和 JWT 鉴权。StatusCode 为 0 视为协议违例返回 502。
懒加载与 Body 编码
EnsureLoaded 实现按需懒加载:被空闲驱逐的插件(DB active 但 services 缺失)在首次请求到达时自动重新加载,singleflight.Group 合并并发加载。前端 auth-bridge 对 503 plugin_unavailable 自动重试,覆盖冷启动窗口。
body 含非 UTF-8 或 multipart 时使用 base64 透传,JS 侧通过 atob() 解码回 latin1 字符串,避免 JSON 序列化损坏二进制数据。
