Skip to content

插件开发框架

本文档基于以下源文件编写:


目录

  1. 架构总览
  2. API Bridge 总览
  3. HTTP API 桥接 -- fetch
  4. Storage API
  5. Logger API
  6. Songs / Playlists / Plugin API
  7. JSEnv API -- 子 JS 环境
  8. Command 执行桥接
  9. 文件系统桥接
  10. 路由注册系统
  11. 权限模型
  12. 公共资源注入
  13. 插件间通信
  14. 嵌入模式
  15. 请求处理生命周期

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.loginfo(msg), warn(msg), error(msg)同步日志,输出到 slog
songloft.storageget(key), set(key, value), delete(key), keys()storageKV 存储,按 entryPath 隔离
songloft.persistentStorageget(key), set(key, value), delete(key), keys()persistent-storage持久化 KV 存储,插件卸载后仍保留
songloft.songslist(options), getById(id), search(query)songs.read歌曲数据库查询
songloft.playlistslist(), getById(id), getSongs(id, options)playlists.read歌单数据库查询
songloft.plugingetToken(), getHostUrl(), getFileUrl(path)无(内置能力)插件自身信息与宿主 URL
songloft.jsenvcreate(), execute(), executeWait(), executeParallel(), destroy(), list()jsenv子 JS 环境管理
songloft.commandexec(), start(), stop(), isRunning(), download(), deleteBin(), listBin(), exists()command外部命令执行与二进制管理
songloft.fsreadFile(), writeFile(), appendFile(), readdir(), unlink(), exists(), mkdir(), stat(), rename()fs / fs:music / fs:external文件系统操作
songloft.commsend(), call(), onMessage()inter-plugin插件间通信
songloft.netudpBind(), 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

javascript
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] 前缀。

javascript
songloft.log.info("插件启动完成");   // → console.log('[plugin] ...')
songloft.log.warn("配置缺失");
songloft.log.error("加载失败");

6. Songs / Playlists / Plugin API

章节来源: internal/jsplugin/api_bridge.go 中的 handleSongshandlePlaylistshandlePlugin 方法

Songs & Playlists(只读数据库查询)

javascript
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.readplaylists.* 操作需要 playlists.read。可在 manifest 中声明 songs.*playlists.* 通配符获取全部读写权限。

Plugin(内置能力,无需权限)

javascript
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 权限。

javascript
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 权限。

一次性执行与后台进程

javascript
// 一次性执行(默认 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");

二进制文件管理

javascript
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/pathfs
"music://xxx"{music_path}/xxxfs:music
"/absolute/path"绝对路径,须在 manifest externalPaths 声明的目录内fs:external

方法清单

javascript
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 白名单内:

json
{ "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 转发流程:

  1. 子路径为空 → 服务 static/index.html
  2. 子路径以 static/ 开头 → 静态文件兜底
  3. 其他子路径 → 按需懒加载插件(EnsureLoaded)→ 构建 HTTPRequestData → 通过 scheduler.Call 同步调用 JS onHTTPRequest → 写入 HTTP 响应

ServeFile 指令:JS 可以返回 { serveFile: { songId: 42 } }{ serveFile: { filePath: "data/file.bin" } },让 Go 层直接 serve 文件(零拷贝、支持 Range),避免通过 QuickJS 的字符串管道传输二进制数据。

publicPaths 免认证路径

json
{
    "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访问管理员配置的外部目录
websocketWebSocket 连接(出站 new WebSocket(...) 与入站 onWebSocket
net原始网络 socket(songloft.net 的 UDP/TCP:出站连接 + 消息推送)

通配符与声明

声明层支持三个通配符:songs.*playlists.*fs.*CheckPermission 通过前缀匹配实现(如 "playlists.*" 匹配 "playlists.read""playlists.write""fs.*" 匹配 "fs""fs:music""fs:external")。

json
{ "permissions": ["storage", "songs.read", "fs:music", "command"], "externalPaths": ["/mnt/nas"] }

运行时校验流程

  1. HandleBridgeCall 入口调用 extractPermFromAction(action) 将 action(如 "songs.list")映射到权限(如 "songs.read"
  2. CheckPermission(plugin.Permissions, required) 比对插件声明的权限列表
  3. 特例:plugin.* 跳过权限检查(内置能力);fs.*resolveFSPath 内按路径类型细粒度检查
  4. 安装时 ValidatePermissions 校验每个值是否在 AllPermissions 白名单内

12. 公共资源注入

章节来源: internal/jsplugin/routes.go 中的 injectHTMLHead 函数, internal/jsplugin/assets/

每个插件的 HTML 页面在返回给浏览器前,会通过 injectHTMLHead 自动注入四个元素到 <head> 标签紧后方(若无 <head> 则注入到文件开头):

注入顺序:<base> → auth-bridge 脚本 → common.css → common.js

<base> 标签

html
<base href="/api/v1/jsplugin/{entryPath}/">

使浏览器以插件路径为基准解析相对路径。必须在所有使用相对 URL 的元素之前注入,否则预加载扫描器会用错误的基准 URL 发起请求。

auth-bridge 脚本

内联脚本完成两个任务:

  1. 从 URL ?access_token=xxx 读取 token 存入 localStorage("songloft-auth"),然后通过 history.replaceState 清理 URL
  2. 包装全局 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

三大职责:

  1. Embed 检测:URL 含 ?embed 参数时给 <html> 添加 .embed class,触发 CSS 中的嵌入模式布局
  2. 主题桥接
    • 初始主题来源优先级:URL ?theme= > localStorage("songloft-theme") > "light"
    • applyTheme(th) 设置 data-theme 属性、切换 theme-light/theme-dark class、持久化到 localStorage、派发 songloft-theme-change 自定义事件
    • 监听 postMessage:父窗口发送 {type: "songloft-theme", theme: "dark"} 时实时切换
  3. window.SongloftPlugin 全局 API(供浏览器端 JS 使用,非 QuickJS 环境):
    • getAuthToken() -- 从 localStorage 读取 JWT token
    • apiGet(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

javascript
// 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: {...} }

接收方注册

javascript
// 注册消息处理器(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=xxx
  • embed -- 触发嵌入模式布局
  • 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-contentpadding-top: 72pxpadding-top: 56px
大屏 margin-left80-100px(给侧边 rail 让位)0(无侧边 rail)

主题实时同步

主应用在运行时切换主题时,通过 postMessage 通知 iframe/WebView:

javascript
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 序列化损坏二进制数据。