Skip to content

Plugin System Design

This document is based on the following source files:

  • internal/jsruntime/runtime.go -- QuickJS runtime wrapper and event loop
  • internal/jsruntime/polyfill.go -- JS standard API polyfills
  • internal/jsruntime/pendingjob.go -- QuickJS native microtask execution
  • internal/jsplugin/manager.go -- Plugin manager (coordinator)
  • internal/jsplugin/plugin.go -- Plugin model and Manifest definition
  • internal/jsplugin/service.go -- Per-plugin service instance
  • internal/jsplugin/scheduler.go -- Message scheduler

Table of Contents

  1. Plugin System Overview
  2. QuickJS Runtime
  3. Polyfill and PendingJob System
  4. Host Bridge Architecture
  5. Message Scheduler
  6. Manager Overview
  7. Plugin Model
  8. 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()SetMaxStackSizeregisterBridgeFunctions → 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):

  1. Attach the Promise to globalThis.__execjs_pending, appending a .then chain to backfill the result
  2. Event loop: pumpAsyncResultsExecutePendingJobsprocessExpiredTimers → check the done flag
  3. When not yet complete, release env.mu and select to wait on asyncSignal / a 50ms tick / ctx.Done / shutdownCh
  4. 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:

CategoryPolyfillImplementation
Consoleconsole.log/error/warn/info/debug/traceDelegates to __go_console, output to Go slog
TimerssetTimeout/clearTimeout/setInterval/clearIntervalPure JS Map + __go_now_ms(); Go side periodically calls __processExpiredTimers
Networkfetch(url, opts)Native Promise + __go_fetch_async background goroutine; supports internal headers X-Fetch-No-Redirect / X-Fetch-Timeout-Ms
EncodingTextEncoder/TextDecoder, btoa/atob__go_buffer_from/to_string + pure JS
BinaryBuffer.from/alloc/concat/isBufferhex internal representation + Go bridge
Cryptocrypto.md5/sha1/sha256Bytes/rc4/aesEncrypt/aesDecrypt/rsaEncrypt/randomBytesGo standard library crypto/*
Compressionzlib.inflate/deflateGo compress/zlib
URLURL/URLSearchParamsPure JS regex parsing
WebSocketWebSocket (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

FunctionPurposeMode
__go_send / __go_consoleEvent dispatch / loggingSynchronous
__go_fetch_asyncHTTP request; internally consumes and strips X-Fetch-No-Redirect / X-Fetch-Timeout-MsTruly async ("fetch:N")
__go_bridgeGeneral bridge (storage/DB/IPC)Truly async ("bridge:N")
__go_pop_async_resultPop a ready async resultSynchronous (non-blocking)
__go_now_ms / __go_buffer_* / __go_crypto_* / __go_zlib_*Utility functionsSynchronous
__go_ws_connect_async / __go_ws_send/close/stateWebSocketAsync 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 TypeDescription
MsgHTTPRequestHTTP route request
MsgInterPluginInter-plugin communication
MsgLifecycleLifecycle (init/deinit)
MsgHealthCheckHealth check
  • Send -- Asynchronous delivery, does not wait for a response
  • Call -- Synchronous call, RespChan + timeout (default 30s)
  • dispatch -- Non-blocking delivery, returns ErrQueueFull when 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

MethodBehavior
LoadPluginCreate JSService + BridgeHandler → service.Load (hash verification / JS environment creation) → register with scheduler → onInit()
UnloadPluginDeregister from scheduler → service.Stop()
ReloadPluginUnload + clear bytecode cache + Load
EnablePluginDB status active + Load, clear self-healing backoff
DisablePluginUnload + 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:

FieldDescription
EntryPathRoute prefix (^[a-z][a-z0-9-]*$)
MainEntry file (.js/.jsc)
PermissionsPermission list (net/storage/fs:music, etc.)
PublicPathsPath prefixes that do not require JWT
ExternalPathsExternal absolute paths that may be accessed
ZipHash / EntryHashTwo-layer integrity verification hashes
StatusStatus: 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
  --------> inactive

8. 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:

  1. 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
  2. Read the entry file → Layer 2 computes SHA256 to verify consistency
  3. 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
  4. Create the JS environment (GetBootstrapCode() + plugin code) → register BridgeCallback → extract the static/bin directories

8.3 Lifecycle

Load() -> Init() -> [HandleMessage 循环] -> Deinit() -> Stop()
CallbackTimeoutDescription
onInit()10sPlugin initialization (register routes / start scheduled tasks)
onDeinit()10sPlugin 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 to ExecuteJS. Supports base64 body encoding and msg.Ctx cancellation. result == "" returns 502 (the handler forgot to return); ctx.Canceled returns 499.
  • MsgInterPlugin: Calls __handleInterPluginMessage(jsonStr) to handle inter-plugin communication
  • MsgHealthCheck: Executes 1+1 and asserts the result is 2
  • MsgLifecycle: Dispatches the init/deinit callbacks

8.5 Stop Flow

The execution order of Stop():

  1. Close the timerStop channel, stopping the timer goroutine
  2. Call Deinit() (errors ignored to ensure subsequent cleanup continues)
  3. bridgeHandler.Cleanup() (terminate background subprocesses)
  4. jsManager.DestroyPluginEnvs(pluginID) -- batch-destroy all associated JS environments (including the main env and child envs)