Skip to content

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

FeatureDescription
Sandbox isolationEach plugin runs in an independent QuickJS virtual machine
Permission controlFine-grained permission declarations, authorized on demand
Hot reloadUpdate plugins at runtime, no service restart required
Inter-plugin communicationsend/call message mechanism
Static assetsBuilt-in Web UI hosting
Health checkAutomatically 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

bash
npx create-songloft-plugin@latest
# or pnpm create songloft-plugin
cd <your-plugin-directory>
npm install   # or pnpm install / yarn install

The scaffolder interactively guides you through the following configuration:

  1. Basic info — directory name, plugin display name, entryPath, description, author
  2. 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
  3. Add-on feature templates (multi-select, skippable) — static pages (static/), executable management (bin/)
  4. 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:

typescript
/// <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;
bash
pnpm run dev          # equivalent to songloft-plugin dev

The first run interactively asks for the Songloft instance address, username, and password, after which it:

  1. Writes the credentials to .songloft-dev.json in the project root (the builder automatically appends it to .gitignore), so subsequent runs log in silently;
  2. Immediately performs a build and upload, automatically enabling the plugin on first install;
  3. Watches src/, static/, and plugin.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:

bash
pnpm run build        # equivalent to songloft-plugin build

The builder will:

  1. Bundle src/main.ts into build/main.js with esbuild (format: iife, target: es2020, references to Node built-in modules are forbidden);
  2. Copy static/ into build/ and inject content hashes into JS/CSS/fonts/images (can be disabled by setting "staticHash": false in plugin.json);
  3. If an available jsc tool is detected, further compile main.js into main.jsc bytecode;
  4. Compute entryHash = sha256(main file) and zipHash (normalized algorithm, excluding plugin.json itself), and write them back to build/plugin.json;
  5. Package everything into dist/<entryPath>.jsplugin.zip and generate dist/<entryPath>.json remote update metadata.

Step 5: Install to a Target Instance

Choose any of the following:

  • Automatic upload in development modepnpm run dev (see Step 3), ideal for local iteration;
  • Upload from the settings page — select dist/<entryPath>.jsplugin.zip on 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 uploadPOST /api/v1/jsplugins/upload, multipart field name file (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

StageBehavior
StartupReads .songloft-dev.json; if username / password is missing it asks interactively, then persists after a successful login
Login strategyDoes 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 uploadCalls POST /api/v1/jsplugins/upload, and automatically calls enable after a fresh install
Subsequent uploadsReuses the upload interface for the same entryPath, which the backend recognizes as an overwrite update; an active plugin is hot-reloaded automatically
File watchingWatches src/, static/, and plugin.json, triggering an incremental build with a 250ms debounce
Password invalidationIf 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

text
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 install

Environment Variables

VariableEquivalent 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):

json
{
  "host": "http://localhost:58091",
  "username": "admin",
  "password": "your-password",
  "pluginId": 12,
  "entryPath": "my-plugin"
}
FieldWhen WrittenDescription
hostOn first startupSongloft instance URL
username / passwordWritten after interactive input on first startup, or can be filled in manuallyUsed to log in each session; stored in plaintext, never commit
pluginId / entryPathWritten after first uploadFor reference only; the dev command actually reconciles with the backend via entryPath

There are no accessToken / refreshToken fields: the dev command does not cache tokens.

Don't want the password stored in plaintext? Use --token <jwt> or $MIMUSIC_TOKEN to provide a pre-issued access token instead; in token mode the credential fields in .songloft-dev.json are 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.js

Common 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

FieldTypeRequiredDescription
namestringYesPlugin name (2-50 characters)
versionstringYesSemantic version number (e.g. 1.0.0)
descriptionstringNoPlugin description
authorstringNoAuthor
homepagestringNoHomepage URL
licensestringNoLicense
entryPathstringYesRoute prefix (lowercase letters + digits + hyphens, e.g. my-plugin)
mainstringYesEntry file path (must end in .js)
minHostVersionstringNoMinimum host version requirement
permissionsstring[]YesPermission list (may be an empty array [])
updateUrlstringNoRemote update check URL
download_urlstringNoPlugin download URL
entryHashstringYessha256(main.js) as 64-character lowercase hex, generated automatically by @songloft/plugin-builder; do not edit manually
zipHashstringYesNormalized 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 / zipHash are mandatory verification fields; when missing or mismatched against the actual content, both installation and loading are rejected by the backend. The zipHash computation excludes plugin.json itself, avoiding the circular dependency caused by writing the hash back into plugin.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.

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

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

javascript
{
    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:

javascript
{
    statusCode: 200,          // HTTP status code
    headers: {                // Response headers
        "Content-Type": "application/json"
    },
    body: "..."               // Response body (string)
}

Example: Route Dispatch

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

javascript
{
    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 message
  • socket.close(code?, reason?): close the connection
  • socket.onMessage(fn) / socket.onClose(fn) / socket.onError(fn): register event callbacks
  • socket.onmessage = fn / socket.addEventListener(...): browser WebSocket-style compatibility

Example: Echo Service

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

javascript
// 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:

  • okstatus >= 200 && status < 300
  • status — HTTP status code
  • statusText — status text
  • headers — response headers object
  • json() — returns Promise<unknown>, parses JSON
  • text() — returns Promise<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.

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

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

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

javascript
// 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:

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

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

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

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

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

javascript
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 APIs
  • getHostUrl() — 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:

PermissionDescription
storageRead/write the plugin's private persistent storage
songs.readRead song metadata
songs.writeModify/write song metadata
songs.*Song read/write wildcard (all-in-one sugar)
playlists.readRead playlists and the songs within them
playlists.writeCreate/modify/delete playlists and their songs
playlists.*Playlist read/write wildcard (all-in-one sugar)
inter-pluginInter-plugin communication
commandExecute external commands / manage executables
jsenvCreate/run child JS sandbox environments
fsRead/write files within the plugin's data directory
fs:musicAccess the music_path music directory
fs:externalAccess administrator-configured external directories
websocketUse new WebSocket(...) to actively connect to external services, or handle inbound onWebSocket upgrades
persistent-storageRead/write persistent storage that remains after the plugin is uninstalled
netUse 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:

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

javascript
// 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:

javascript
// 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:

javascript
// 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 logic

Common assets (CSS variables/reset/MD3 component styles, font files, the common.js API 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):

  1. <base> — sets the relative path baseline, so relative paths in the HTML can directly reference static/... and the plugin API
  2. Auth bridge script — writes the URL ?access_token= into localStorage, and automatically retries on a fetch 503
  3. common.css — MD3 color variables (including light/dark dual themes), CSS reset, font declarations, common component styles
  4. common.js — embed detection, theme bridging, the window.SongloftPlugin global 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:

javascript
// 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:

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

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

css
/* 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:

  1. Update the data-theme attribute and the theme-light/theme-dark CSS classes on <html>
  2. Dispatch a songloft-theme-change CustomEvent
  3. 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:

  1. Layer 1 — ZIP Hash: the SHA256 of the entire ZIP file
  2. 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

bash
# 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.html

File Naming

ZIP file name format: {entryPath}.jsplugin.zip

The system extracts the entryPath from the file name: my-plugin.jsplugin.zipmy-plugin

Installation Methods

  1. Development mode (recommended): iterate locally with songloft-plugin dev, see §2.6
  2. UI upload: upload the ZIP via the Songloft client's settings page → plugin management
  3. Directory placement: drop the ZIP into the server's data/jsplugins/ directory, and it is discovered automatically when the service starts
  4. API upload: POST /api/v1/jsplugins/upload, multipart field name file (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 /upload endpoint handles both fresh installs and overwrite updates, which the backend distinguishes via response status code 201 / 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 processing

Automatic 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 dev running and just save the source;
  • Operations: re-upload a ZIP with the same entryPath (POST /api/v1/jsplugins/upload) or call PUT /api/v1/jsplugins/{id}; the backend automatically triggers a hot reload for plugins in the active state after a successful update;
  • Remote update: call POST /api/v1/jsplugins/{id}/update to pull the new version from updateUrl, 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

  1. Avoid long-running blockingonHTTPRequest should return quickly
  2. Use timers wisely — timer callbacks execute in a separate thread and do not block HTTP requests. However, network operations such as fetch inside a callback still hold the VM lock, so avoid running multiple serial network requests in a single callback
  3. Cache computed results — use songloft.storage to cache frequently accessed data
  4. Control response body size — avoid returning overly large JSON responses
  5. Timer intervals — we recommend a setInterval interval of no less than 1 second; the system checks for due timers every 500ms

Error Handling

javascript
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 updateUrl in plugin.json to support remote update checks
  • Bump the major version number on breaking changes

Development and Debugging

  1. Check the output prefixed with [plugin] in the server logs
  2. Use songloft.log.info/warn/error to output debug information
  3. Health check failures are recorded in the logs

Storage Usage Pattern

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

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