Skip to content

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 validation
  • internal/jsplugin/scheduler.go -- Message scheduler (Skynet-style message dispatch)
  • internal/jsplugin/assets/common.css -- MD3 color system and component styles
  • internal/jsplugin/assets/common.js -- embed detection + theme bridge + SongloftPlugin global API
  • internal/jsruntime/polyfill.go -- fetch polyfill (truly async HTTP)
  • internal/jsruntime/runtime.go -- QuickJS runtime management

Table of Contents

  1. Architecture Overview
  2. API Bridge Overview
  3. HTTP API Bridge -- fetch
  4. Storage API
  5. Logger API
  6. Songs / Playlists / Plugin API
  7. JSEnv API -- Child JS Environments
  8. Command Execution Bridge
  9. File System Bridge
  10. Route Registration System
  11. Permission Model
  12. Shared Asset Injection
  13. Inter-Plugin Communication
  14. Embedded Mode
  15. 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 here
  • onHTTPRequest(req) -- Receives an HTTP request and returns a {statusCode, headers, body} response
  • onWebSocket(req, socket) -- Receives an inbound WebSocket upgrade (requires the websocket permission), registering message/close/error callbacks
  • onDeinit() -- 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:

NamespaceMethodsRequired PermissionDescription
songloft.loginfo(msg), warn(msg), error(msg)NoneSynchronous logging, output to slog
songloft.storageget(key), set(key, value), delete(key), keys()storageKV storage, isolated by entryPath
songloft.persistentStorageget(key), set(key, value), delete(key), keys()persistent-storagePersistent KV storage, retained even after the plugin is uninstalled
songloft.songslist(options), getById(id), search(query)songs.readSong database queries
songloft.playlistslist(), getById(id), getSongs(id, options)playlists.readPlaylist database queries
songloft.plugingetToken(), getHostUrl(), getFileUrl(path)None (built-in capability)The plugin's own information and the host URL
songloft.jsenvcreate(), execute(), executeWait(), executeParallel(), destroy(), list()jsenvChild JS environment management
songloft.commandexec(), start(), stop(), isRunning(), download(), deleteBin(), listBin(), exists()commandExternal command execution and binary management
songloft.fsreadFile(), writeFile(), appendFile(), readdir(), unlink(), exists(), mkdir(), stat(), rename()fs / fs:music / fs:externalFile system operations
songloft.commsend(), call(), onMessage()inter-pluginInter-plugin communication
songloft.netudpBind(), udpSend(), udpJoinMulticast(), udpLeaveMulticast(), udpGetLocalAddr(), udpClose(), tcpConnect()netRaw 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.

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");

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.

javascript
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)

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 });

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)

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=..."

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.

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");

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

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");

Binary File Management

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");

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 FormatResolution RuleRequired Permission
"relative/path"{pluginDataDir}/{entryPath}/relative/pathfs
"music://xxx"{music_path}/xxxfs:music
"/absolute/path"Absolute path, must be within a directory declared in the manifest's externalPathsfs:external

Method List

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");

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:

json
{ "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:

  1. Empty subpath → serve static/index.html
  2. Subpath starting with static/ → static file fallback
  3. Other subpaths → lazy-load the plugin on demand (EnsureLoaded) → build HTTPRequestData → synchronously call the JS onHTTPRequest via scheduler.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

json
{
    "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 IdentifierDescription
storagePersistent KV storage (isolated by entryPath)
persistent-storagePersistent KV storage, retained even after the plugin is uninstalled
songs.readRead the song database
songs.writeModify the song database
playlists.readRead the playlist database
playlists.writeModify the playlist database
inter-pluginInter-plugin communication (send/call)
commandExecute external commands and manage binary files
jsenvCreate/execute child JS environments
fsRead/write files within the plugin data directory
fs:musicAccess the music_path music directory
fs:externalAccess external directories configured by the administrator
websocketWebSocket connections (outbound new WebSocket(...) and inbound onWebSocket)
netRaw 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").

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

Runtime Validation Flow

  1. At the entry of HandleBridgeCall, extractPermFromAction(action) maps the action (such as "songs.list") to a permission (such as "songs.read")
  2. CheckPermission(plugin.Permissions, required) compares against the plugin's declared permission list
  3. Special cases: plugin.* skips the permission check (built-in capability); fs.* is checked at fine granularity by path type inside resolveFSPath
  4. At install time, ValidatePermissions validates that each value is within the AllPermissions allowlist

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

html
<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:

  1. Reads the token from the URL ?access_token=xxx and stores it in localStorage("songloft-auth"), then cleans the URL via history.replaceState
  2. Wraps the global fetch, automatically waiting 200ms and retrying once on a 503 plugin_unavailable response 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:

  1. Embed detection: When the URL contains the ?embed parameter, it adds the .embed class to <html>, triggering the embedded-mode layout in the CSS
  2. Theme bridge:
    • Initial theme source priority: URL ?theme= > localStorage("songloft-theme") > "light"
    • applyTheme(th) sets the data-theme attribute, toggles the theme-light/theme-dark class, persists to localStorage, and dispatches the songloft-theme-change custom event
    • Listens for postMessage: switches in real time when the parent window sends {type: "songloft-theme", theme: "dark"}
  3. window.SongloftPlugin global API (for use by browser-side JS, not the QuickJS environment):
    • getAuthToken() -- Read the JWT token from localStorage
    • apiGet(path) / apiPost(path, body) / apiPut(path, body) / apiDelete(path) -- Wrap fetch requests, automatically carrying the Authorization header
    • getTheme() -- 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

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

Receiver Registration

javascript
// 注册消息处理器(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 type MsgInterPlugin and delivers it to the target plugin's message queue via scheduler.Send(), without waiting for a response
  • Call() uses scheduler.Call() to synchronously wait for the target plugin to finish processing and return an InterPluginResponse

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=xxx
  • embed -- Triggers the embedded-mode layout
  • theme -- Passes the current theme
  • access_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:

ElementStandalone ModeEmbedded Mode
.app-barFixed top 56pxHidden (display: none)
.tab-barBottom 64px (80px left rail on large screens)Top 48px horizontal tab bar
.tab-itemVertical icon + textHorizontal icon + text, 14px font size
.tab-contentpadding-top: 72pxpadding-top: 56px
Large-screen margin-left80-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:

javascript
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.