Plugin Development Framework
This document is based on the following source files:
internal/jsplugin/api_bridge.go-- Host API bridge (bootstrap JS + BridgeHandler dispatch)internal/jsplugin/api_bridge_command.go-- Command execution bridge (exec/start/stop/download)internal/jsplugin/api_bridge_fs.go-- File system bridge (music:// protocol, external paths)internal/jsplugin/routes.go-- Route registration (static files, API forwarding, publicPaths, shared assets)internal/jsplugin/permissions.go-- Permission model (declaration-layer allowlist + runtime validation)internal/jsplugin/communication.go-- Inter-plugin communication (send/call + onMessage handler)internal/jsplugin/invoke.go-- Internal HTTP invocation (InvokeHTTP bypasses routing to reach the plugin directly)internal/jsplugin/service.go-- Request handling lifecycle (JSService Actor model)internal/jsplugin/manager.go-- Plugin manager (load/unload/lazy-load/hot-reload)internal/jsplugin/plugin.go-- PluginManifest structure and validationinternal/jsplugin/scheduler.go-- Message scheduler (Skynet-style message dispatch)internal/jsplugin/assets/common.css-- MD3 color system and component stylesinternal/jsplugin/assets/common.js-- embed detection + theme bridge + SongloftPlugin global APIinternal/jsruntime/polyfill.go-- fetch polyfill (truly async HTTP)internal/jsruntime/runtime.go-- QuickJS runtime management
Table of Contents
- Architecture Overview
- API Bridge Overview
- HTTP API Bridge -- fetch
- Storage API
- Logger API
- Songs / Playlists / Plugin API
- JSEnv API -- Child JS Environments
- Command Execution Bridge
- File System Bridge
- Route Registration System
- Permission Model
- Shared Asset Injection
- Inter-Plugin Communication
- Embedded Mode
- Request Handling Lifecycle
1. Architecture Overview
Section sources: internal/jsplugin/api_bridge.go, internal/jsplugin/service.go, internal/jsplugin/manager.go
Songloft's JS plugin system runs inside a QuickJS sandbox, with each plugin owning an independent JS virtual machine instance. Plugins interact with the Go backend through the host-provided songloft global object; all host APIs uniformly return a Promise, and plugin code must call them with await.
Core layering:
+---------------------------+
| 插件 JS 代码 (QuickJS) | ← 开发者编写
+---------------------------+
| songloft.* API 层 | ← pluginBootstrapJS 注入
+---------------------------+
| __callBridge 传输层 | ← polyfill 提供
+---------------------------+
| BridgeHandler 分发器 | ← api_bridge.go
+---------------------------+
| Go 服务层 | ← database/services/os
+---------------------------+Each API call is initiated on the JS side through __callBridge(action, data); the bridge layer routes the action string (such as "storage.get", "songs.list") to the Go-side BridgeHandler.HandleBridgeCall, dispatching to the corresponding handler method based on the action prefix.
Plugin lifecycle callbacks:
onInit()-- Called after the plugin finishes loading; can register message handlers and initialize state hereonHTTPRequest(req)-- Receives an HTTP request and returns a{statusCode, headers, body}responseonWebSocket(req, socket)-- Receives an inbound WebSocket upgrade (requires thewebsocketpermission), registering message/close/error callbacksonDeinit()-- Called before the plugin is unloaded to clean up resources
All three callbacks must be assigned by overriding the default implementation via globalThis.xxx = ...; they cannot use a function declaration (QuickJS Annex B block-level function declarations create a declarative binding, causing the override to fail).
2. API Bridge Overview
Section sources: the pluginBootstrapJS constant and the HandleBridgeCall method in internal/jsplugin/api_bridge.go
The host injects the songloft global object via pluginBootstrapJS when each plugin VM starts. Below is the complete list of API namespaces and methods:
| Namespace | Methods | Required Permission | Description |
|---|---|---|---|
songloft.log | info(msg), warn(msg), error(msg) | None | Synchronous logging, output to slog |
songloft.storage | get(key), set(key, value), delete(key), keys() | storage | KV storage, isolated by entryPath |
songloft.persistentStorage | get(key), set(key, value), delete(key), keys() | persistent-storage | Persistent KV storage, retained even after the plugin is uninstalled |
songloft.songs | list(options), getById(id), search(query) | songs.read | Song database queries |
songloft.playlists | list(), getById(id), getSongs(id, options) | playlists.read | Playlist database queries |
songloft.plugin | getToken(), getHostUrl(), getFileUrl(path) | None (built-in capability) | The plugin's own information and the host URL |
songloft.jsenv | create(), execute(), executeWait(), executeParallel(), destroy(), list() | jsenv | Child JS environment management |
songloft.command | exec(), start(), stop(), isRunning(), download(), deleteBin(), listBin(), exists() | command | External command execution and binary management |
songloft.fs | readFile(), writeFile(), appendFile(), readdir(), unlink(), exists(), mkdir(), stat(), rename() | fs / fs:music / fs:external | File system operations |
songloft.comm | send(), call(), onMessage() | inter-plugin | Inter-plugin communication |
songloft.net | udpBind(), udpSend(), udpJoinMulticast(), udpLeaveMulticast(), udpGetLocalAddr(), udpClose(), tcpConnect() | net | Raw network sockets (UDP/TCP, outbound connections + message push) |
fetch() | -- | None (runtime polyfill) | Standard fetch API, truly async HTTP; supports internal control headers |
Permission check flow: at the entry of HandleBridgeCall, the required permission is first extracted from the action (extractPermFromAction), then compared against the permission list declared in the plugin manifest. plugin.* operations are built-in capabilities and skip the permission check; the permission for fs.* operations is checked at fine granularity by path type inside resolveFSPath.
3. HTTP API Bridge -- fetch
Section sources: internal/jsruntime/polyfill.go, internal/jsruntime/runtime.go
The fetch() function in plugins is a polyfill implementation of the standard Web API, using a truly asynchronous design:
JS 调 fetch(url, opts)
→ new Promise → 注册到 __asyncCallbacks
→ 调 __go_fetch_async(url, method, headers, body)
→ Go goroutine 并行执行 HTTP 请求
→ 完成后通过 asyncResults channel 投递结果
→ 事件循环 pump 阶段 resolve Promise
→ .then() 回调在下一轮微任务执行Design motivation: QuickJS is a single-threaded VM; if fetch blocked synchronously, it would stall the entire env's message queue. The truly-async approach lets a Go-side goroutine run the HTTP request in parallel, the JS side returns a Promise immediately, and the VM lock is released during this period, not affecting other operations (such as the health-check probe).
The returned Response object supports standard methods like .text() and .json(). Each async call is associated with the JS-side __asyncCallbacks registry through an ID in the "fetch:N" format, and __resolveAsync decides how to wrap the payload based on the type prefix (the fetch type is wrapped into a Response object).
fetch supports two runtime-internal request headers: X-Fetch-No-Redirect prevents the Go HTTP client from automatically following redirects, making it easy for plugins to handle login redirect chains themselves; X-Fetch-Timeout-Ms sets the single-request timeout to a value within the 100-30000ms range. These two headers are consumed only internally at runtime and are not forwarded to the target server.
4. Storage API
Section sources: the handleStorage method in internal/jsplugin/api_bridge.go
Per-plugin isolated persistent KV storage, with data stored in the data/jsplugins_data/{entryPath}/data/ directory, where each key corresponds to one file. Values are automatically JSON.stringify/JSON.parse-ed on the JS side.
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");Security restrictions: the key must not be empty and must not contain /, \, or .. (to prevent directory traversal); the storage directory is created automatically on demand.
5. Logger API
Section sources: the songloft.log definition in internal/jsplugin/api_bridge.go
The only synchronous API (it does not return a Promise), mapped to the QuickJS console methods and ultimately bridged through __go_console to Go slog, with a [plugin] prefix.
songloft.log.info("插件启动完成"); // → console.log('[plugin] ...')
songloft.log.warn("配置缺失");
songloft.log.error("加载失败");6. Songs / Playlists / Plugin API
Section sources: the handleSongs, handlePlaylists, and handlePlugin methods in internal/jsplugin/api_bridge.go
Songs & Playlists (read-only database queries)
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 });Permission mapping: songs.* operations require songs.read; playlists.* operations require playlists.read. You can declare the songs.* or playlists.* wildcard in the manifest to obtain full read/write permissions.
Plugin (built-in capability, no permission required)
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=..."The path assembled by getFileUrl is served directly by the Go-native http.ServeFile through the /files/* route, supporting Range requests and HTTP caching.
7. JSEnv API -- Child JS Environments
Section sources: the handleJSEnv method in internal/jsplugin/api_bridge.go
Allows creating isolated child environments in independent QuickJS VMs, used to run user scripts or coordinate multiple workers in parallel. Requires the jsenv permission.
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");Namespace isolation: the JS-side name is automatically prefixed on the bridge side to {rootEnvID}::{name}, and DestroyPluginEnvs(pluginID) automatically reclaims all child environments when the plugin is unloaded. The name must not contain :: or /.
8. Command Execution Bridge
Section sources: internal/jsplugin/api_bridge_command.go
The Command API allows executing external commands and managing binary files; it requires the command permission.
One-Shot Execution and Background Processes
// 一次性执行(默认 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");Binary File Management
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");Security restrictions: the program name resolution priority is the plugin's bin/ directory > system PATH; the filename is validated against ^[a-zA-Z0-9][a-zA-Z0-9._-]*$; stdout/stderr are each capped at 10MB; downloads are capped at 500MB; a missing ELF interpreter is automatically reported (glibc/musl incompatibility); and Cleanup() automatically terminates all background processes when the plugin is unloaded.
9. File System Bridge
Section sources: internal/jsplugin/api_bridge_fs.go
The FS API provides file system operations, with permissions checked at fine granularity by path type.
Three Path Forms
| Path Format | Resolution Rule | Required Permission |
|---|---|---|
"relative/path" | {pluginDataDir}/{entryPath}/relative/path | fs |
"music://xxx" | {music_path}/xxx | fs:music |
"/absolute/path" | Absolute path, must be within a directory declared in the manifest's externalPaths | fs:external |
Method List
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");Security restrictions: all paths are forbidden from containing ..; filepath.Abs + HasPrefix ensures they cannot escape the base directory; the file size is capped at 10MB; fs:external must be within the manifest's externalPaths allowlist:
{ "permissions": ["fs:external"], "externalPaths": ["/mnt/nas/music", "/data/shared"] }10. Route Registration System
Section sources: internal/jsplugin/routes.go
JS plugin routes are divided into three layers, registered by the Manager to the chi Router at startup.
RegisterStaticRoutes (No Authentication Required)
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/字体)Static file serving does not depend on whether the plugin JS runtime is ready; it only requires that static/ exists in the data directory. This ensures that during plugin initialization (before onInit completes) the frontend page can still load normally.
HTML files are returned after being injected with a <base> tag, the auth-bridge script, and shared-asset references, with Cache-Control: no-cache set; other static assets are set to Cache-Control: public, max-age=31536000, immutable for strong caching.
SPA fallback: paths that do not match fall back to index.html.
RegisterAPIRoutes (Authentication Required)
GET/HEAD /api/v1/jsplugin/{entryPath}/files/* → 文件直接 serve(Go 原生 ServeFile)
* /api/v1/jsplugin/{entryPath}/* → catch-all API 转发到 JS 运行时API forwarding flow:
- Empty subpath → serve
static/index.html - Subpath starting with
static/→ static file fallback - Other subpaths → lazy-load the plugin on demand (
EnsureLoaded) → buildHTTPRequestData→ synchronously call the JSonHTTPRequestviascheduler.Call→ write the HTTP response
ServeFile directive: JS can return { serveFile: { songId: 42 } } or { serveFile: { filePath: "data/file.bin" } } to let the Go layer serve the file directly (zero-copy, Range-supported), avoiding transferring binary data through the QuickJS string pipe.
publicPaths Auth-Exempt Paths
{
"publicPaths": ["/api/callback", "/webhook"]
}A plugin can declare publicPaths in its manifest; these subpaths are joined into /api/v1/jsplugin/{entryPath}{publicPath} and registered to the skip list of AuthMiddleware. RefreshPublicPaths is called after a plugin is installed/uninstalled/enabled/disabled/hot-reloaded, reloading all plugins' publicPaths from the DB into the in-memory cache; at runtime, IsPublicPath(path) uses prefix matching to determine whether to skip JWT authentication.
Dynamic Route Mounting
The {entryPath} in all routes is determined at runtime by the installed plugins, not statically registered. chi's route-priority mechanism ensures GET requests to /static/* or /files/* paths match the more specific routes first and do not fall into the catch-all.
11. Permission Model
Section sources: internal/jsplugin/permissions.go
Permission Constants
| Permission Identifier | Description |
|---|---|
storage | Persistent KV storage (isolated by entryPath) |
persistent-storage | Persistent KV storage, retained even after the plugin is uninstalled |
songs.read | Read the song database |
songs.write | Modify the song database |
playlists.read | Read the playlist database |
playlists.write | Modify the playlist database |
inter-plugin | Inter-plugin communication (send/call) |
command | Execute external commands and manage binary files |
jsenv | Create/execute child JS environments |
fs | Read/write files within the plugin data directory |
fs:music | Access the music_path music directory |
fs:external | Access external directories configured by the administrator |
websocket | WebSocket connections (outbound new WebSocket(...) and inbound onWebSocket) |
net | Raw network sockets (songloft.net's UDP/TCP: outbound connections + message push) |
Wildcards and Declaration
The declaration layer supports three wildcards: songs.*, playlists.*, and fs.*. CheckPermission is implemented through prefix matching (e.g., "playlists.*" matches "playlists.read" and "playlists.write"; "fs.*" matches "fs", "fs:music", and "fs:external").
{ "permissions": ["storage", "songs.read", "fs:music", "command"], "externalPaths": ["/mnt/nas"] }Runtime Validation Flow
- At the entry of
HandleBridgeCall,extractPermFromAction(action)maps the action (such as"songs.list") to a permission (such as"songs.read") CheckPermission(plugin.Permissions, required)compares against the plugin's declared permission list- Special cases:
plugin.*skips the permission check (built-in capability);fs.*is checked at fine granularity by path type insideresolveFSPath - At install time,
ValidatePermissionsvalidates that each value is within theAllPermissionsallowlist
12. Shared Asset Injection
Section sources: the injectHTMLHead function in internal/jsplugin/routes.go, internal/jsplugin/assets/
Before each plugin's HTML page is returned to the browser, injectHTMLHead automatically injects four elements immediately after the <head> tag (or at the start of the file if there is no <head>):
注入顺序:<base> → auth-bridge 脚本 → common.css → common.js<base> Tag
<base href="/api/v1/jsplugin/{entryPath}/">Makes the browser resolve relative paths relative to the plugin path. It must be injected before all elements that use relative URLs, otherwise the preload scanner will issue requests using the wrong base URL.
auth-bridge Script
The inline script performs two tasks:
- Reads the token from the URL
?access_token=xxxand stores it inlocalStorage("songloft-auth"), then cleans the URL viahistory.replaceState - Wraps the global
fetch, automatically waiting 200ms and retrying once on a503 plugin_unavailableresponse under the plugin API path (to work with the backend's lazy-load cold start)
common.css -- MD3 Color System
Diagram sources: internal/jsplugin/assets/common.css
Defines a Material Design 3 CSS variable system (--md-primary, --md-surface, --md-on-surface, --md-error, etc.), covering both light and dark themes (switched via the html[data-theme="dark"] selector). Provides MD3 component classes: .card, .btn-*, .text-field, .chip, .dialog, .snackbar, .list-item, .switch, .progress-linear, .tab-bar/.tab-item, etc. Embeds the Roboto and Material Symbols Outlined fonts, strongly cached for 1 year.
common.js -- Theme Bridge and Utility API
Diagram sources: internal/jsplugin/assets/common.js
Three main responsibilities:
- Embed detection: When the URL contains the
?embedparameter, it adds the.embedclass to<html>, triggering the embedded-mode layout in the CSS - Theme bridge:
- Initial theme source priority: URL
?theme=>localStorage("songloft-theme")>"light" applyTheme(th)sets thedata-themeattribute, toggles thetheme-light/theme-darkclass, persists to localStorage, and dispatches thesongloft-theme-changecustom event- Listens for
postMessage: switches in real time when the parent window sends{type: "songloft-theme", theme: "dark"}
- Initial theme source priority: URL
window.SongloftPluginglobal API (for use by browser-side JS, not the QuickJS environment):getAuthToken()-- Read the JWT token from localStorageapiGet(path)/apiPost(path, body)/apiPut(path, body)/apiDelete(path)-- Wrap fetch requests, automatically carrying the Authorization headergetTheme()-- Return the current theme ("light"or"dark")onThemeChange(callback)-- Register a theme-change callback
13. Inter-Plugin Communication
Section sources: internal/jsplugin/communication.go, the handleComm method in internal/jsplugin/api_bridge.go
Inter-plugin communication is implemented through the songloft.comm namespace and requires the inter-plugin permission.
Sender 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: {...} }Receiver Registration
// 注册消息处理器(handler 可返回值或 Promise)
songloft.comm.onMessage("refresh", async function(payload, from) {
console.log("收到来自", from, "的刷新请求");
return { updated: true };
});Internal Implementation
Communicator holds a reference to ServiceScheduler:
Send()builds a message of typeMsgInterPluginand delivers it to the target plugin's message queue viascheduler.Send(), without waiting for a responseCall()usesscheduler.Call()to synchronously wait for the target plugin to finish processing and return anInterPluginResponse
The JS-side __handleInterPluginMessage(msgJSON) is the internal entry function, called by the Go-side ExecuteJS. It parses the message JSON, looks up the corresponding action handler registered in songloft.comm._handlers, executes it, and wraps the result as {success, data, error} to return. The handler can be an async function, and the framework will automatically await it.
14. Embedded Mode
Section sources: internal/jsplugin/assets/common.css (embed-related styles), internal/jsplugin/assets/common.js (embed detection), internal/jsplugin/routes.go (auth-bridge + theme parameter passing)
A plugin can be embedded as a tab into the Songloft main application, rendered via a WebView (mobile) or an iframe (Web).
Trigger Mechanism
The main application adds URL parameters when loading the plugin page:
/api/v1/jsplugin/{entryPath}?embed&theme=dark&access_token=xxxembed-- Triggers the embedded-mode layouttheme-- Passes the current themeaccess_token-- Passes the authentication token (the auth-bridge script automatically stores it in localStorage and cleans the URL)
Layout Changes
After common.js detects the ?embed parameter, it adds the .embed class to <html>, and the html.embed rules in common.css override the default layout:
| Element | Standalone Mode | Embedded Mode |
|---|---|---|
.app-bar | Fixed top 56px | Hidden (display: none) |
.tab-bar | Bottom 64px (80px left rail on large screens) | Top 48px horizontal tab bar |
.tab-item | Vertical icon + text | Horizontal icon + text, 14px font size |
.tab-content | padding-top: 72px | padding-top: 56px |
Large-screen margin-left | 80-100px (making room for the side rail) | 0 (no side rail) |
Real-Time Theme Sync
When the main application switches the theme at runtime, it notifies the iframe/WebView via postMessage:
iframe.contentWindow.postMessage({ type: "songloft-theme", theme: "dark" }, "*");common.js listens for the message event and, upon receiving it, calls applyTheme() to switch the CSS variables and class, achieving refresh-free theme sync.
15. Request Handling Lifecycle
Section sources: internal/jsplugin/service.go, internal/jsplugin/manager.go, internal/jsplugin/invoke.go
External HTTP Request Handling Flow
浏览器请求 → 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 响应Internal Invocation (InvokeHTTP)
Section sources: internal/jsplugin/invoke.go
Manager.InvokeHTTP provides a channel for server-side business logic (such as SourceFetcher) to call a plugin handler directly, bypassing the chi router and JWT authentication. A StatusCode of 0 is treated as a protocol violation and returns 502.
Lazy Loading and Body Encoding
EnsureLoaded implements on-demand lazy loading: a plugin evicted for being idle (DB active but services missing) is automatically reloaded when the first request arrives, and singleflight.Group merges concurrent loads. The frontend auth-bridge automatically retries on 503 plugin_unavailable, covering the cold-start window.
When the body contains non-UTF-8 or multipart data, it is passed through as base64, and the JS side decodes it back to a latin1 string via atob(), avoiding JSON serialization corrupting the binary data.
