Plugin System Design
This document is based on the following source files:
internal/jsruntime/runtime.go-- QuickJS runtime wrapper and event loopinternal/jsruntime/polyfill.go-- JS standard API polyfillsinternal/jsruntime/pendingjob.go-- QuickJS native microtask executioninternal/jsplugin/manager.go-- Plugin manager (coordinator)internal/jsplugin/plugin.go-- Plugin model and Manifest definitioninternal/jsplugin/service.go-- Per-plugin service instanceinternal/jsplugin/scheduler.go-- Message scheduler
Table of Contents
- Plugin System Overview
- QuickJS Runtime
- Polyfill and PendingJob System
- Host Bridge Architecture
- Message Scheduler
- Manager Overview
- Plugin Model
- Service Instance
1. Plugin System Overview
Section sources: internal/jsruntime/runtime.go, internal/jsplugin/manager.go
Songloft's JS plugin system allows third-party developers to extend server capabilities in a sandboxed environment (audio source integration, Web UI pages, cross-plugin communication, etc.). The key considerations behind choosing QuickJS:
- Sandbox security: Each plugin owns a fully isolated VM instance (heap memory, global objects); all external capabilities must be obtained through explicitly registered bridge functions.
- CGO-free: Implemented via
modernc.org/quickjs(pure Go translated from the C source), requiring no CGO, guaranteeing zero extra dependencies for cross-compilation across all platforms. - Deterministic execution: A single-threaded engine where all JS execution is serialized through a
sync.Mutex, with no race conditions.
The overall architecture is divided into three layers:
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 Runtime
Section sources: internal/jsruntime/runtime.go
2.1 Core Structs
Diagram sources: runtime.go:92-117 (JSEnv struct field definitions)
JSEnvManager manages all JS environments within the process, and JSEnv represents an independent VM instance:
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 Environment Creation
CreateEnv creates a new environment: quickjs.NewVM() → SetMaxStackSize → registerBridgeFunctions → inject polyfills → execute initialization code → record ownership relationships. CreateEnvWithBytecode is a variant path that first executes the bootstrap source and then loads precompiled bytecode (.jsc), used for loading from cache after a restart.
2.3 Execution Model
ExecuteJS implements a complete asynchronous event loop:
Fast path: When eval(code) returns a non-thenable value with no in-flight async tasks, it returns directly (internal calls such as health probes and timers take this path).
Slow path (async Promise):
- Attach the Promise to
globalThis.__execjs_pending, appending a.thenchain to backfill the result - Event loop:
pumpAsyncResults→ExecutePendingJobs→processExpiredTimers→ check the done flag - When not yet complete, release
env.muandselectto wait on asyncSignal / a 50ms tick / ctx.Done / shutdownCh - After being woken up, re-acquire the lock and continue
Key guarantee: env.mu is released during an async await, so health probes, timers, and other requests to the same plugin can all acquire the lock — a single 30s fetch will not freeze the entire plugin.
2.4 Health Probe and Parallel Execution
HealthProbe probes the VM directly via TryLock (bypassing the scheduler queue) and returns one of four states: Healthy/Unhealthy/Busy/Missing. ExecuteJSParallel supports racing execution across multiple environments (the multi-source search scenario), launching goroutines in windowed batches and returning the first successful result immediately.
3. Polyfill and PendingJob System
Section sources: internal/jsruntime/polyfill.go, internal/jsruntime/pendingjob.go
3.1 Polyfill Manifest
QuickJS provides only the ES2023 language specification. polyfillJS is injected when each VM is created, filling in the standard interfaces:
| Category | Polyfill | Implementation |
|---|---|---|
| Console | console.log/error/warn/info/debug/trace | Delegates to __go_console, output to Go slog |
| Timers | setTimeout/clearTimeout/setInterval/clearInterval | Pure JS Map + __go_now_ms(); Go side periodically calls __processExpiredTimers |
| Network | fetch(url, opts) | Native Promise + __go_fetch_async background goroutine; supports internal headers X-Fetch-No-Redirect / X-Fetch-Timeout-Ms |
| Encoding | TextEncoder/TextDecoder, btoa/atob | __go_buffer_from/to_string + pure JS |
| Binary | Buffer.from/alloc/concat/isBuffer | hex internal representation + Go bridge |
| Crypto | crypto.md5/sha1/sha256Bytes/rc4/aesEncrypt/aesDecrypt/rsaEncrypt/randomBytes | Go standard library crypto/* |
| Compression | zlib.inflate/deflate | Go compress/zlib |
| URL | URL/URLSearchParams | Pure JS regex parsing |
| WebSocket | WebSocket (connect/send/close/events) | __go_ws_* + gorilla/websocket |
The polyfill also includes a Function.prototype.toString override that marks bridge functions as [native code], for compatibility with the anti-debugging checks in jsjiami.com v7 obfuscated scripts.
3.2 Async Callback Registry
__asyncCallbacks (Map<id, {resolve, reject}>) is the core of the JS-side async infrastructure: when fetch/bridge creates a Promise, resolve/reject are stored; after the Go goroutine completes, it pushes to env.asyncResults, and the event loop calls __pumpAsyncResults → __resolveAsync, which wraps the payload based on type and resolves. WebSocket messages are dispatched directly through __wsRegistry.
3.3 PendingJob and Timers
pendingjob.go accesses the VM's internal fields via unsafe/reflect, calling JS_ExecutePendingJob to advance native Promise microtasks. SetMaxStackSize sets the stack size to 0 (using the default MaxStackSlots=1000), preventing obfuscated code from recursing for anti-debugging purposes.
The Go-side timer handling has three paths: processExpiredTimers inside the ExecuteJS event loop (50ms tick); the JSService.runTimerProcessor dedicated goroutine (500ms period, non-blocking TryLock); and the processJobs full loop (async results + microtasks + timers, exiting after 500ms of no progress).
4. Host Bridge Architecture
Section sources: internal/jsruntime/runtime.go:1326-1720
Go and JS communicate through global functions registered via vm.RegisterFunc. Time-consuming operations follow a truly asynchronous pattern: the JS call returns an ID immediately, a background goroutine executes, and the result is sent back through the asyncResults channel.
4.1 Bridge Function Manifest
| Function | Purpose | Mode |
|---|---|---|
__go_send / __go_console | Event dispatch / logging | Synchronous |
__go_fetch_async | HTTP request; internally consumes and strips X-Fetch-No-Redirect / X-Fetch-Timeout-Ms | Truly async ("fetch:N") |
__go_bridge | General bridge (storage/DB/IPC) | Truly async ("bridge:N") |
__go_pop_async_result | Pop a ready async result | Synchronous (non-blocking) |
__go_now_ms / __go_buffer_* / __go_crypto_* / __go_zlib_* | Utility functions | Synchronous |
__go_ws_connect_async / __go_ws_send/close/state | WebSocket | Async connect, synchronous operations |
4.2 Async Result Flow
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 链继续The actual handling of __go_bridge is performed by BridgeHandler.HandleBridgeCall, which dispatches the action to Go services (storage/songs/playlists/cross-plugin communication/subprocesses, etc.); it is the sole entry point through which plugins access host capabilities.
5. Message Scheduler
Section sources: internal/jsplugin/scheduler.go
ServiceScheduler draws on the Skynet Actor model, providing each plugin with a worker goroutine + message queue (cap=256):
| Message Type | Description |
|---|---|
MsgHTTPRequest | HTTP route request |
MsgInterPlugin | Inter-plugin communication |
MsgLifecycle | Lifecycle (init/deinit) |
MsgHealthCheck | Health check |
- Send -- Asynchronous delivery, does not wait for a response
- Call -- Synchronous call,
RespChan+ timeout (default 30s) - dispatch -- Non-blocking delivery, returns
ErrQueueFullwhen the queue is full
The worker processes messages serially, guaranteeing that requests to the same plugin do not run concurrently. Before processing, it checks msg.Ctx.Err(): requests already abandoned by the client are skipped directly, preventing the worker from being stuck on stale requests.
6. Manager Overview
Section sources: internal/jsplugin/manager.go
Manager is the top-level coordinator of the plugin system, holding the Repository (SQLite persistence), PackageManager (ZIP install/sync), ServiceScheduler, JSEnvManager, HealthChecker, HotReloader, and a singleflight.Group (lazy-load deduplication).
6.1 Startup Flow
Start(ctx) executes in sequence: SyncPluginsFromDirectory (ZIP → DB sync) → loadPlugins (load all active plugins) → RefreshPublicPaths → start HealthChecker → start HotReloader.
6.2 Core Operations
| Method | Behavior |
|---|---|
LoadPlugin | Create JSService + BridgeHandler → service.Load (hash verification / JS environment creation) → register with scheduler → onInit() |
UnloadPlugin | Deregister from scheduler → service.Stop() |
ReloadPlugin | Unload + clear bytecode cache + Load |
EnablePlugin | DB status active + Load, clear self-healing backoff |
DisablePlugin | Unload + DB status inactive |
6.3 Lazy Loading (EnsureLoaded)
When a request arrives and the target plugin is not loaded (possibly due to idle eviction), EnsureLoaded loads it on demand. It uses singleflight.Group to deduplicate by entryPath: 50 concurrent requests execute LoadPlugin only once. inactive in the DB returns 403, error returns 503 (HealthChecker self-heals with exponential backoff), and non-existent returns 404.
6.4 Shutdown Flow
Close() guarantees deadlock-freedom: jsManager.SignalShutdown() → stop HealthChecker → cancel context → iterate over services to deregister/Stop → close scheduler → close jsManager (tryLockWithTimeout 3s fallback).
7. Plugin Model
Section sources: internal/jsplugin/plugin.go, internal/models/models.go:519-543
7.1 JSPlugin Struct
Defined in the models package, referenced by the jsplugin package via a type alias. Key fields:
| Field | Description |
|---|---|
EntryPath | Route prefix (^[a-z][a-z0-9-]*$) |
Main | Entry file (.js/.jsc) |
Permissions | Permission list (net/storage/fs:music, etc.) |
PublicPaths | Path prefixes that do not require JWT |
ExternalPaths | External absolute paths that may be accessed |
ZipHash / EntryHash | Two-layer integrity verification hashes |
Status | Status: active/inactive/error |
7.2 Manifest and Validation
plugin.json is generated by @songloft/plugin-builder during packaging. ValidateManifest validates: name 2-50 characters, version semver, entryPath starting with a lowercase letter, main ending with .js/.jsc, and entryHash/zipHash being 64-character lowercase hex.
7.3 State Machine
安装/启用 加载失败 / 健康检查异常
--------> active -----------------------> error
^ |
| HealthChecker 指数退避自愈 |
+--------------------------------+
|
用户禁用 v
--------> inactive8. Service Instance
Section sources: internal/jsplugin/service.go
8.1 JSService Structure
A per-plugin Actor that implements the MessageHandler interface. It holds plugin metadata, envID, scheduler, jsManager, and BridgeHandler, and maintains status (ready/running/frozen/stopped) and lastActive (used for idle-eviction determination).
8.2 Loading Flow (Load)
Two-layer hash verification + code loading:
- Read the ZIP → Layer 1 computes a normalized ZIP hash (excluding plugin.json); when mtime is unchanged but the hash mismatches, it is judged as tampering
- Read the entry file → Layer 2 computes SHA256 to verify consistency
- Bytecode cache: if the ZIP ships its own
.jsc, use it directly; if a valid cache exists, load it; otherwise use the source and compile the cache asynchronously - Create the JS environment (
GetBootstrapCode()+ plugin code) → register BridgeCallback → extract the static/bin directories
8.3 Lifecycle
Load() -> Init() -> [HandleMessage 循环] -> Deinit() -> Stop()| Callback | Timeout | Description |
|---|---|---|
onInit() | 10s | Plugin initialization (register routes / start scheduled tasks) |
onDeinit() | 10s | Plugin cleanup (close connections / save state) |
After Init() completes, it starts the runTimerProcessor goroutine (500ms period, TryLock to process JS timers).
8.4 Request Handling
HandleMessage dispatches by message type:
- MsgHTTPRequest: Serialize the request to JSON, wrap it as
(async function(){return JSON.stringify(await onHTTPRequest(req));})(), and hand it toExecuteJS. Supports base64 body encoding andmsg.Ctxcancellation.result == ""returns 502 (the handler forgot to return);ctx.Canceledreturns 499. - MsgInterPlugin: Calls
__handleInterPluginMessage(jsonStr)to handle inter-plugin communication - MsgHealthCheck: Executes
1+1and asserts the result is 2 - MsgLifecycle: Dispatches the init/deinit callbacks
8.5 Stop Flow
The execution order of Stop():
- Close the
timerStopchannel, stopping the timer goroutine - Call
Deinit()(errors ignored to ensure subsequent cleanup continues) bridgeHandler.Cleanup()(terminate background subprocesses)jsManager.DestroyPluginEnvs(pluginID)-- batch-destroy all associated JS environments (including the main env and child envs)
