JS Plugin Development Guide
This document details the architecture, API, and development workflow of the Songloft JS plugin system.
1. Overview
The Songloft JS plugin system lets developers extend the music server with JavaScript, without compiling Go code.
Design Philosophy
The system is designed around the Skynet Actor model:
- Each plugin is an independent Actor (JSService) with its own JS virtual machine
- Plugins communicate with each other through messages, without interfering with one another
- All messages are dispatched uniformly by the ServiceScheduler, guaranteeing serial processing
- Two-layer SHA256 verification ensures the integrity of plugin code
Core Features
| Feature | Description |
|---|---|
| Sandbox isolation | Each plugin runs in an independent QuickJS virtual machine |
| Permission control | Fine-grained permission declarations, authorized on demand |
| Hot reload | Update plugins at runtime, no service restart required |
| Inter-plugin communication | send/call message mechanism |
| Static assets | Built-in Web UI hosting |
| Health check | Automatically detects and handles misbehaving plugins |
Architecture Diagram
Manager
├── PackageManager (package management: install/update/uninstall)
├── ServiceScheduler (message scheduler)
│ ├── JSService[plugin-a] (Actor + QuickJS VM)
│ ├── JSService[plugin-b] (Actor + QuickJS VM)
│ └── ...
├── HotReloader (hot reload monitor)
└── HealthChecker (health check)2. Quick Start
We recommend using the official toolchain songloft-plugin-toolchain to create, build, and upload your first JS plugin in 5 minutes.
Step 1: Create a Project with the Scaffolder
npx create-songloft-plugin@latest
# or pnpm create songloft-plugin
cd <your-plugin-directory>
npm install # or pnpm install / yarn installThe scaffolder interactively guides you through the following configuration:
- Basic info — directory name, plugin display name, entryPath, description, author
- Permission selection (multi-select) —
storage,persistent-storage,songs.read,songs.write,playlists.read,playlists.write,inter-plugin,command,jsenv,fs,fs:music,fs:external,websocket,net - Add-on feature templates (multi-select, skippable) — static pages (
static/), executable management (bin/) - Package manager — npm / pnpm / yarn
Generated project structure (when all add-on features are selected):
my-plugin/
├── plugin.json # Plugin manifest (entryHash / zipHash generated by builder)
├── package.json # npm dependencies (@songloft/plugin-sdk / @songloft/plugin-builder)
├── tsconfig.json
├── src/
│ └── main.ts # TypeScript source entry
├── static/ # [add-on] Static assets (HTML + plugin custom JS)
│ ├── index.html
│ └── js/
│ └── app.js
└── bin/ # [add-on] Executable management (bundle/download/run external programs)The templates use an overlay design: the base template is always included, and each selected add-on feature merges its corresponding files on top.
Step 2: Write Business Logic
src/main.ts uses the global types and helpers provided by @songloft/plugin-sdk:
/// <reference types="@songloft/plugin-sdk" />
import { jsonResponse, createRouter } from '@songloft/plugin-sdk';
const router = createRouter();
router.get('/hello', (req) => jsonResponse({ message: 'Hello!', query: req.query }));
router.get('/songs', (req) => {
const songs = songloft.songs.list({ limit: 10 });
return jsonResponse({ count: songs.length, songs });
});
function onInit(): void { songloft.log.info('my-plugin initialized'); }
function onDeinit(): void { songloft.log.info('my-plugin deinitialized'); }
function onHTTPRequest(req: HTTPRequest): HTTPResponse { return router.handle(req); }
// @ts-expect-error — QuickJS global injection
globalThis.onInit = onInit;
// @ts-expect-error
globalThis.onDeinit = onDeinit;
// @ts-expect-error
globalThis.onHTTPRequest = onHTTPRequest;Step 3: Start Development Mode (Recommended)
pnpm run dev # equivalent to songloft-plugin devThe first run interactively asks for the Songloft instance address, username, and password, after which it:
- Writes the credentials to
.songloft-dev.jsonin the project root (the builder automatically appends it to.gitignore), so subsequent runs log in silently; - Immediately performs a build and upload, automatically enabling the plugin on first install;
- Watches
src/,static/, andplugin.json; on source changes it automatically rebuilds and uploads, and any active plugin is automatically hot-reloaded by the backend.
Tokens are not cached: every session logs in on the fly with the username and password, so you never have to worry about token expiration / refresh. To switch accounts or change the password, edit (or simply delete)
.songloft-dev.json.
The console prints the plugin's access entry point (e.g. http://localhost:58091/api/v1/jsplugin/<entryPath>/); press Ctrl+C to exit.
For the full CLI options, environment variables, and config file fields of development mode, see Development Mode In Depth below.
Step 4: Build a Production Package
Generate a distributable .jsplugin.zip before release:
pnpm run build # equivalent to songloft-plugin buildThe builder will:
- Bundle
src/main.tsintobuild/main.jswith esbuild (format: iife,target: es2020, references to Node built-in modules are forbidden); - Copy
static/intobuild/and inject content hashes into JS/CSS/fonts/images (can be disabled by setting"staticHash": falseinplugin.json); - If an available
jsctool is detected, further compilemain.jsintomain.jscbytecode; - Compute
entryHash = sha256(main file)andzipHash(normalized algorithm, excludingplugin.jsonitself), and write them back tobuild/plugin.json; - Package everything into
dist/<entryPath>.jsplugin.zipand generatedist/<entryPath>.jsonremote update metadata.
Step 5: Install to a Target Instance
Choose any of the following:
- Automatic upload in development mode —
pnpm run dev(see Step 3), ideal for local iteration; - Upload from the settings page — select
dist/<entryPath>.jsplugin.zipon the plugin management page of the Songloft client; - Directory placement — drop the zip into the server's
data/jsplugins/directory, and it is scanned automatically on the next startup; - API upload —
POST /api/v1/jsplugins/upload, multipart field namefile(this is the interface development mode uses under the hood).
After installation, the plugin's HTTP API is accessed via /api/v1/jsplugin/<entryPath>/, and static assets via /api/v1/jsplugin/<entryPath>/static/....
Development Mode In Depth (songloft-plugin dev)
songloft-plugin dev compresses "build → upload → hot reload" into a single resident command, ideal for local development and debugging against a remote instance.
Default Behavior
| Stage | Behavior |
|---|---|
| Startup | Reads .songloft-dev.json; if username / password is missing it asks interactively, then persists after a successful login |
| Login strategy | Does not cache tokens; logs in on the fly with the credentials on each startup, and automatically re-logs in with the same password if a 401 occurs during the session |
| First upload | Calls POST /api/v1/jsplugins/upload, and automatically calls enable after a fresh install |
| Subsequent uploads | Reuses the upload interface for the same entryPath, which the backend recognizes as an overwrite update; an active plugin is hot-reloaded automatically |
| File watching | Watches src/, static/, and plugin.json, triggering an incremental build with a 250ms debounce |
| Password invalidation | If the server rejects the cached password (e.g. it was changed), the password field in .songloft-dev.json is automatically cleared and you are prompted to rerun |
CLI Options
songloft-plugin dev [options]
--host <url> Songloft instance URL (default http://localhost:58091,
can also read $MIMUSIC_HOST or .songloft-dev.json)
--username <name> Login username (or $MIMUSIC_USER)
--password <pwd> Login password (or $MIMUSIC_PASSWORD; prompts silently if omitted)
--token <jwt> Use a pre-issued access token directly (or $MIMUSIC_TOKEN)
--once Build + upload once, then exit, skipping watch
--no-enable Do not automatically enable the plugin after first installEnvironment Variables
| Variable | Equivalent Option |
|---|---|
MIMUSIC_HOST | --host |
MIMUSIC_USER | --username |
MIMUSIC_PASSWORD | --password |
MIMUSIC_TOKEN | --token |
.songloft-dev.json Fields
The dev command automatically maintains the following config file in the project root (and appends it to .gitignore):
{
"host": "http://localhost:58091",
"username": "admin",
"password": "your-password",
"pluginId": 12,
"entryPath": "my-plugin"
}| Field | When Written | Description |
|---|---|---|
host | On first startup | Songloft instance URL |
username / password | Written after interactive input on first startup, or can be filled in manually | Used to log in each session; stored in plaintext, never commit |
pluginId / entryPath | Written after first upload | For reference only; the dev command actually reconciles with the backend via entryPath |
There are no
accessToken/refreshTokenfields: the dev command does not cache tokens.Don't want the password stored in plaintext? Use
--token <jwt>or$MIMUSIC_TOKENto provide a pre-issued access token instead; in token mode the credential fields in.songloft-dev.jsonare neither read nor written.Deleting the entire file is equivalent to resetting the login state.
3. Plugin Structure
ZIP Packaging Format
Plugins are distributed in .jsplugin.zip format, with the file naming rule: {entryPath}.jsplugin.zip
Internal ZIP structure (all files at the root level, no parent directory):
plugin.json # Plugin manifest (required)
main.js # Entry file (required, or main.jsc bytecode)
static/ # Static assets directory (optional)
├── index.html
└── js/
└── app.jsCommon assets (CSS variables/reset/MD3 component styles, fonts, the API utility library) are injected automatically by the main program; plugins do not need to bundle them. See §8. Static Assets for details.
plugin.json Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Plugin name (2-50 characters) |
version | string | Yes | Semantic version number (e.g. 1.0.0) |
description | string | No | Plugin description |
author | string | No | Author |
homepage | string | No | Homepage URL |
license | string | No | License |
entryPath | string | Yes | Route prefix (lowercase letters + digits + hyphens, e.g. my-plugin) |
main | string | Yes | Entry file path (must end in .js) |
minHostVersion | string | No | Minimum host version requirement |
permissions | string[] | Yes | Permission list (may be an empty array []) |
updateUrl | string | No | Remote update check URL |
download_url | string | No | Plugin download URL |
entryHash | string | Yes | sha256(main.js) as 64-character lowercase hex, generated automatically by @songloft/plugin-builder; do not edit manually |
zipHash | string | Yes | Normalized sha256 of all files in the zip except plugin.json, as 64-character lowercase hex, generated automatically by @songloft/plugin-builder; do not edit manually |
entryHash/zipHashare mandatory verification fields; when missing or mismatched against the actual content, both installation and loading are rejected by the backend. ThezipHashcomputation excludesplugin.jsonitself, avoiding the circular dependency caused by writing the hash back intoplugin.json.
entryPath Naming Rules
- Only lowercase letters, digits, and hyphens are allowed
- Must start with a lowercase letter
- Regex:
^[a-z][a-z0-9-]*$ - Examples:
example-basic,music-sync,metadata-helper
4. Lifecycle
Plugins have three core lifecycle callback functions:
onInit()
Called after the plugin finishes loading. Used to initialize resources, set up timers, etc.
function onInit() {
songloft.log.info("Plugin initialized");
songloft.storage.set("start_time", new Date().toISOString());
}Note: An onInit() failure does not prevent the plugin from running; the plugin can still respond to HTTP requests.
onDeinit()
Called before the plugin is unloaded. Used to clean up resources and save state.
function onDeinit() {
songloft.log.info("Plugin shutting down, saving state...");
}onHTTPRequest(req)
Called when an HTTP request is received. This is the main entry point through which a plugin provides its service.
Structure of the req parameter:
{
method: "GET", // HTTP method
path: "/songs", // Request path (relative to the plugin's entryPath)
headers: {}, // Request headers map
body: "", // Request body (for POST/PUT)
query: "limit=10&offset=0" // URL query string
}Return value structure:
{
statusCode: 200, // HTTP status code
headers: { // Response headers
"Content-Type": "application/json"
},
body: "..." // Response body (string)
}Example: Route Dispatch
function onHTTPRequest(req) {
switch (req.path) {
case "/":
case "":
return { statusCode: 200, body: "Hello!", headers: {} };
case "/api/data":
if (req.method === "POST") {
return handlePost(req);
}
return handleGet(req);
default:
return { statusCode: 404, body: "Not Found", headers: {} };
}
}onWebSocket(req, socket)
Called when a client connects to /api/v1/jsplugin/{entryPath}/... and initiates a WebSocket upgrade. The plugin must declare the websocket permission. onWebSocket should register message/close/error callbacks and then return; the connection lifecycle is managed by the host.
Structure of the req parameter:
{
method: "GET",
path: "/api/inbound",
headers: {},
query: "access_token=...",
remoteAddr: "127.0.0.1:12345"
}Common socket methods:
socket.send(string | Uint8Array | ArrayBuffer): send a text or binary messagesocket.close(code?, reason?): close the connectionsocket.onMessage(fn)/socket.onClose(fn)/socket.onError(fn): register event callbackssocket.onmessage = fn/socket.addEventListener(...): browser WebSocket-style compatibility
Example: Echo Service
globalThis.onWebSocket = async function(req, socket) {
socket.onMessage(async function(event) {
await socket.send(event.data);
});
};5. API Reference
All APIs are accessed through the global songloft object.
HTTP Requests (Global fetch)
Use the standard global fetch function to make HTTP requests (provided by a runtime polyfill, returns a Promise). No permission declaration required.
// GET
const resp = await fetch("https://example.com/api");
const data = await resp.json();
// POST
const postResp = await fetch("https://example.com/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hello: "world" })
});
const text = await postResp.text();The request headers may use two runtime-internal control headers: X-Fetch-No-Redirect disables automatic redirect following, and X-Fetch-Timeout-Ms sets the per-request timeout (100-30000ms). These two headers only affect runtime behavior and are not forwarded to the target server.
Response object fields:
ok—status >= 200 && status < 300status— HTTP status codestatusText— status textheaders— response headers objectjson()— returnsPromise<unknown>, parses JSONtext()— returnsPromise<string>, raw text
onHTTPRequest, onWebSocket, and event callbacks can all be async function; the framework waits for the Promise to settle.
Crypto (Global crypto)
The runtime provides a lightweight crypto utility object. No permission declaration required.
const md5 = crypto.md5("data");
const sha256 = crypto.sha256Bytes(Buffer.from("data", "utf8")).toString("hex");
const key = Buffer.from("1234567890abcdef", "utf8");
const iv = Buffer.from("abcdef1234567890", "utf8");
const encrypted = crypto.aesEncrypt("hello", "cbc", key, iv).toString("base64");
const decrypted = crypto.aesDecrypt(encrypted, "cbc", key, iv).toString("utf8");Common methods: md5(str), sha1(str), sha256Bytes(buffer), rc4(key, data), aesEncrypt(buffer, "cbc" | "ecb", key, iv?), aesDecrypt(buffer, "cbc" | "ecb", key, iv?), rsaEncrypt(buffer, publicKeyPEM), randomBytes(size). AES uses PKCS7 padding; a string ciphertext passed to aesDecrypt is parsed as base64 by default, while a Buffer is parsed as raw bytes.
Timers (Global setTimeout / setInterval)
Use the standard global timer APIs (provided by a runtime polyfill). No permission declaration required; the runtime automatically cleans up any uncleared timers when the plugin is unloaded.
// One-shot delay
const t = setTimeout(() => songloft.log.info("tick"), 1000);
clearTimeout(t);
// Periodic execution
const i = setInterval(() => songloft.log.info("heartbeat"), 60000);
clearInterval(i);Note: Timer callbacks execute in a separate background goroutine (which checks for due timers every 500ms) and use a TryLock mechanism to ensure they do not block HTTP request handling. While an HTTP request is being processed, the timer automatically yields and waits for the next round. The minimum interval for setInterval is capped at 10ms.
songloft.storage — Persistent Storage
Requires permission: storage
// Read a value (returns a string or null)
var value = songloft.storage.get("key");
// Write a value
songloft.storage.set("key", "value");
// Delete a key
songloft.storage.delete("key");
// Get all key names
var keys = songloft.storage.keys(); // ["key1", "key2", ...]Storage limitations:
- Keys are strings
- Values are strings (complex objects must be JSON-serialized manually)
- Each plugin has its own independent storage space
songloft.songs — Song Operations
Requires permission: songs.read
// Get the song list
var songs = songloft.songs.list({ limit: 20, offset: 0 });
// Get a song by ID
var song = songloft.songs.getById(123);
// Search songs
var results = songloft.songs.search("keyword");Song object structure:
{
id: 1,
type: "local", // "local" | "remote" | "radio"
title: "Song title",
artist: "Artist",
album: "Album name",
duration: 240.5, // seconds
file_path: "/path/to/file.mp3",
url: "",
cover_path: ""
}songloft.playlists — Playlist Operations
Requires permission: playlists.read (read) or playlists.write (modify); or the wildcard sugar playlists.*.
// Requires playlists.read
var playlists = songloft.playlists.list();
var playlist = songloft.playlists.getById(1);
var songs = songloft.playlists.getSongs(1, { limit: 50, offset: 0 });songloft.comm — Inter-Plugin Communication
Requires permission: inter-plugin
// Send a message asynchronously (fire-and-forget)
songloft.comm.send("target-plugin", "action-name", { data: "hello" });
// Synchronous call (waits for a response, default timeout 10s)
var resp = songloft.comm.call("target-plugin", "action-name", { data: "hello" }, 5000);
// resp = { success: true, data: { ... } }
// Register a message handler
songloft.comm.onMessage("action-name", function(payload, from) {
// payload: data passed by the sender
// from: the sender's entryPath
return { result: "processed" }; // return value is the response to call
});songloft.log — Logging
No permission required.
songloft.log.info("informational message");
songloft.log.warn("warning message");
songloft.log.error("error message");Logs are written to the server's standard log with a [plugin] prefix.
songloft.plugin — Plugin Information
No permission required.
// Get the plugin's JWT Token (for accessing host APIs, such as authenticated resources like music files and covers)
var token = songloft.plugin.getToken();
// Get the base URL of the host service (e.g. http://192.168.1.100:58091)
var hostUrl = songloft.plugin.getHostUrl();Typical use: building an authenticated resource URL
function getMusicUrl(songId) {
var host = songloft.plugin.getHostUrl();
var token = songloft.plugin.getToken();
return host + "/music/" + encodedPath + "?access_token=" + token;
}Method reference:
getToken()— returns the currently valid JWT access_token string, usable for accessing the host's protected APIsgetHostUrl()— returns the base URL of the host service, used to build complete API or resource addresses
6. Permission System
A plugin must declare the permissions it needs in the permissions field of plugin.json. Permissions are checked when APIs are called at runtime, and undeclared permissions are rejected.
Available Permissions
Consistent with AllPermissions in the backend's internal/jsplugin/permissions.go:
| Permission | Description |
|---|---|
storage | Read/write the plugin's private persistent storage |
songs.read | Read song metadata |
songs.write | Modify/write song metadata |
songs.* | Song read/write wildcard (all-in-one sugar) |
playlists.read | Read playlists and the songs within them |
playlists.write | Create/modify/delete playlists and their songs |
playlists.* | Playlist read/write wildcard (all-in-one sugar) |
inter-plugin | Inter-plugin communication |
command | Execute external commands / manage executables |
jsenv | Create/run child JS sandbox environments |
fs | Read/write files within the plugin's data directory |
fs:music | Access the music_path music directory |
fs:external | Access administrator-configured external directories |
websocket | Use new WebSocket(...) to actively connect to external services, or handle inbound onWebSocket upgrades |
persistent-storage | Read/write persistent storage that remains after the plugin is uninstalled |
net | Use raw network sockets (currently UDP) |
Note: capabilities such as network requests (
fetch), timers (setTimeout/setInterval), and logging require no permission declaration; they are default host capabilities.
Wildcard Sugar
A permission ending in .* acts as all-in-one sugar at the declaration layer, and the runner uses prefix matching when checking. For example, declaring playlists.* covers both playlists.read and playlists.write; whereas declaring only playlists.read cannot call write interfaces.
Principle of Least Privilege
Declare only the permissions you actually need to reduce security risk:
{
"permissions": ["storage", "songs.read"]
}7. Inter-Plugin Communication
Plugins can collaborate with each other through a messaging mechanism.
Asynchronous Send
The sender does not wait for a response, suitable for notification scenarios:
// Plugin A: notify Plugin B
songloft.comm.send("plugin-b", "data-updated", { source: "plugin-a" });Synchronous Call
The sender waits for the receiver to process and return a result:
// Plugin A: call Plugin B's service
var response = songloft.comm.call("plugin-b", "get-data", { id: 123 }, 5000);
if (response.success) {
var data = response.data;
}Register a Handler (onMessage)
The receiver registers a function to handle a specific action:
// Plugin B: register an action handler
songloft.comm.onMessage("get-data", function(payload, from) {
songloft.log.info("Request from: " + from);
// payload = { id: 123 }
return { name: "example", value: 42 };
});
songloft.comm.onMessage("data-updated", function(payload, from) {
songloft.log.info("Got notification from: " + from);
// No return value needed (send scenario)
});Communication Permission
Both communicating parties need the inter-plugin permission.
8. Static Assets
A plugin can provide a Web UI through the static/ directory.
Directory Structure
my-plugin/
├── plugin.json
├── main.js
└── static/
├── index.html
└── js/
└── app.js # Plugin custom logicCommon assets (CSS variables/reset/MD3 component styles, font files, the
common.jsAPI utility library) are injected automatically by the main program and do not need to be bundled in the plugin.
Automatic Injection by the Main Program
When returning a plugin's HTML page, the backend automatically injects the following at the top of <head> (in order):
<base>— sets the relative path baseline, so relative paths in the HTML can directly referencestatic/...and the plugin API- Auth bridge script — writes the URL
?access_token=into localStorage, and automatically retries on a fetch 503 common.css— MD3 color variables (including light/dark dual themes), CSS reset, font declarations, common component stylescommon.js— embed detection, theme bridging, thewindow.SongloftPluginglobal API
Therefore a plugin's HTML does not need:
<link>references to fonts.css or style.css (provided by the main program)- an embed detection script (provided by the main program)
- bundled font files (the main program serves them via
/api/v1/jsplugin-assets/fonts/)
window.SongloftPlugin — Browser-Side Global API
The common.js injected by the main program exposes the window.SongloftPlugin global object, which provides the following methods:
// API requests
SongloftPlugin.getAuthToken() // Read the JWT token from localStorage
SongloftPlugin.apiGet(path) // GET request, returns Promise<JSON>
SongloftPlugin.apiPost(path, body) // POST request
SongloftPlugin.apiPut(path, body) // PUT request
SongloftPlugin.apiDelete(path) // DELETE request
// Theme
SongloftPlugin.getTheme() // Returns 'light' | 'dark'
SongloftPlugin.onThemeChange(cb) // Listen for theme changes, cb(theme: 'light' | 'dark')Plugin JS can use them directly:
const { apiGet, getTheme, onThemeChange } = SongloftPlugin;
const data = await apiGet('/api/hello');
console.log('current theme:', getTheme());
onThemeChange(theme => console.log('theme switched to:', theme));If a plugin has multiple JS files, just destructure from the global at the top of each file:
const { apiGet, apiPost } = SongloftPlugin;Theme Adaptation
The main program's common.css defines --md-* CSS variables under :root (light values), and overrides them with dark values under html[data-theme="dark"]. Plugin pages that use these variables adapt to the theme automatically:
/* Plugin custom styles — referencing --md-* variables automatically follows the theme */
.my-card {
background: var(--md-surface);
color: var(--md-on-surface);
border: 1px solid var(--md-outline-variant);
}When the theme changes (the user switches it in the main program's settings), common.js will:
- Update the
data-themeattribute and thetheme-light/theme-darkCSS classes on<html> - Dispatch a
songloft-theme-changeCustomEvent - Write to
localStorage['songloft-theme']
Plugin JS can listen for theme changes via SongloftPlugin.onThemeChange(callback) to perform additional handling.
Access Paths
After installation, static files are accessed via the following paths (note: the runtime route is the singular jsplugin, different from the management API /api/v1/jsplugins, which is plural):
GET /api/v1/jsplugin/{entryPath}/ → static/index.html (auto-injected)
GET /api/v1/jsplugin/{entryPath}/static → static/index.html
GET /api/v1/jsplugin/{entryPath}/static/<file> → any static asset
GET /api/v1/jsplugin-assets/* → main program common assets (CSS/JS/fonts)Notes
- Static files are extracted from the ZIP to
data/jsplugins_data/{entryPath}/static/at install time - Static files are re-extracted when the plugin is updated
- Use relative paths to reference the plugin API
- Common assets are provided by the main program; plugins need not, and should not, bundle their own CSS variables/fonts/API utility library
9. Security Mechanisms
Two-Layer Hash Verification
The plugin system uses two layers of SHA256 verification to protect code integrity:
- Layer 1 — ZIP Hash: the SHA256 of the entire ZIP file
- Layer 2 — Entry Hash: the SHA256 of the entry file (main.js) content
Verification Flow
When loading a plugin:
1. Compute the ZIP file SHA256 → compare with zip_hash in the database
2. If it doesn't match:
- Check whether the file mtime has changed
- mtime unchanged = file tampered → refuse to load
- mtime changed = legitimate update → allow and update the hash
3. Read main.js from the ZIP in memory (not written to disk)
4. Compute the main.js SHA256 → compare with entry_hash
5. If it doesn't match and the ZIP hash is unchanged → reject (internal tampering)main.js Is Not Written to Disk
The entry file is read directly from the ZIP into memory and is not written to the disk file system, reducing the risk of tampering.
Permission Isolation
- Each plugin declares its permissions, which are strictly checked at runtime
- API calls for undeclared permissions are rejected
- The QuickJS virtual machine provides runtime isolation
10. Packaging and Publishing
Packaging Steps
# 1. Make sure the directory structure is correct
my-plugin/
├── plugin.json
├── main.js
└── static/
└── index.html
# 2. Enter the plugin directory
cd my-plugin/
# 3. Package into a ZIP (files at the root level, no parent directory)
zip -r ../my-plugin.jsplugin.zip plugin.json main.js static/
# 4. Verify the ZIP structure
unzip -l ../my-plugin.jsplugin.zip
# You should see:
# plugin.json
# main.js
# static/index.htmlFile Naming
ZIP file name format: {entryPath}.jsplugin.zip
The system extracts the entryPath from the file name: my-plugin.jsplugin.zip → my-plugin
Installation Methods
- Development mode (recommended): iterate locally with
songloft-plugin dev, see §2.6 - UI upload: upload the ZIP via the Songloft client's settings page → plugin management
- Directory placement: drop the ZIP into the server's
data/jsplugins/directory, and it is discovered automatically when the service starts - API upload:
POST /api/v1/jsplugins/upload, multipart field namefile(this is the interface development mode uses under the hood)
Updating an Existing Plugin
- Simply re-upload a new-version ZIP with the same
entryPath(the/uploadendpoint handles both fresh installs and overwrite updates, which the backend distinguishes via response status code201/200) - You can also explicitly call
PUT /api/v1/jsplugins/{id}to upload the new ZIP - Or directly replace the ZIP file in the
data/jsplugins/directory
Regardless of the method, if the original plugin is in the active state, the backend automatically triggers a hot reload after the update succeeds.
11. Hot Reload
Plugins support runtime updates without restarting the Songloft service.
Hot Reload Flow
1. Detect a ZIP file change (mtime changed)
2. Freeze the current service (stop accepting new messages)
3. Call the onDeinit() callback
4. Destroy the old QuickJS virtual machine
5. Reload the code from the new ZIP
6. Create a new QuickJS virtual machine
7. Call the onInit() callback
8. Unfreeze the service, resume message processingAutomatic Detection
The system polls the data/jsplugins/ directory every 30 seconds to detect changes in ZIP file mtime. If a change is detected, a hot reload is triggered automatically.
Manual Triggering
There is currently no standalone reload endpoint. Common ways to re-trigger a hot reload:
- During development: keep
songloft-plugin devrunning and just save the source; - Operations: re-upload a ZIP with the same
entryPath(POST /api/v1/jsplugins/upload) or callPUT /api/v1/jsplugins/{id}; the backend automatically triggers a hot reload for plugins in theactivestate after a successful update; - Remote update: call
POST /api/v1/jsplugins/{id}/updateto pull the new version fromupdateUrl, which also hot-reloads automatically.
Error Rollback
If the new version fails to load, the system attempts to roll back to the old version. If the rollback also fails, the plugin is marked as being in the error state.
Notes
- During a hot reload, requests currently being processed are completed before switching
- Timers and storage state need to be re-initialized after a hot reload
- We recommend restoring necessary state in
onInit()
12. Best Practices
Performance Tips
- Avoid long-running blocking —
onHTTPRequestshould return quickly - Use timers wisely — timer callbacks execute in a separate thread and do not block HTTP requests. However, network operations such as
fetchinside a callback still hold the VM lock, so avoid running multiple serial network requests in a single callback - Cache computed results — use
songloft.storageto cache frequently accessed data - Control response body size — avoid returning overly large JSON responses
- Timer intervals — we recommend a
setIntervalinterval of no less than 1 second; the system checks for due timers every 500ms
Error Handling
function onHTTPRequest(req) {
try {
// Business logic
var data = processRequest(req);
return {
statusCode: 200,
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" }
};
} catch (e) {
songloft.log.error("Request failed: " + e.message);
return {
statusCode: 500,
body: JSON.stringify({ error: e.message }),
headers: { "Content-Type": "application/json" }
};
}
}Version Management
- Follow semantic versioning (SemVer)
- Set
updateUrlinplugin.jsonto support remote update checks - Bump the major version number on breaking changes
Development and Debugging
- Check the output prefixed with
[plugin]in the server logs - Use
songloft.log.info/warn/errorto output debug information - Health check failures are recorded in the logs
Storage Usage Pattern
// Store a complex object
function saveConfig(config) {
songloft.storage.set("config", JSON.stringify(config));
}
function loadConfig() {
var raw = songloft.storage.get("config");
return raw ? JSON.parse(raw) : { defaultKey: "defaultValue" };
}Inter-Plugin Collaboration Pattern
// Service provider pattern
songloft.comm.onMessage("get-service", function(payload, from) {
switch (payload.method) {
case "translate":
return { text: translate(payload.text) };
case "summarize":
return { summary: summarize(payload.text) };
default:
return { error: "unknown method" };
}
});
// Service consumer pattern
function useTranslation(text) {
var resp = songloft.comm.call("translator-plugin", "get-service", {
method: "translate",
text: text
}, 5000);
if (resp.success && resp.data) {
return resp.data.text;
}
return text; // fallback
}Appendix: Complete Example
See the plugin-toolchain/examples/basic directory for complete example plugin code based on the official toolchain.
