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,tags.read,tags.write,inter-plugin,command,jsenv,fs,fs:music,fs:external,websocket,net,net:insecure-tls(wildcard sugarssongs.*,playlists.*,tags.*,fs.*can be used in the manifest) - Add-on feature templates (multi-select, skippable) — static pages (
static/), executable management (bin/), Lynx native rendering (renderEngine: "lynx", ReactLynx + cross-platform native UI) - 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', async (req) => {
const songs = await 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 []) |
renderEngine | string | No | Engine the client uses to render the plugin UI: webview / lynx; missing or empty string means webview. See renderEngine — Declaring the Rendering Engine |
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
renderEngine — Declaring the Rendering Engine
Native clients have two paths for rendering a plugin UI: the system WebView, and Lynx (cross-platform native UI driven by ReactLynx). Which one is used is declared by the plugin itself in plugin.json — the host has no global engine switch, and plugins do not affect each other.
{
"entryPath": "my-plugin",
"renderEngine": "lynx"
}| Value | Meaning |
|---|---|
field missing / "" | Same as webview, i.e. the host default |
"webview" | Rendered by the system WebView (default) |
"lynx" | Lynx native rendering (ReactLynx compiled to .lynx.bundle, loaded by the host via <frame>; an index.html + .web.bundle is auto-generated for clients that don't support Lynx to fall back to WebView) |
- Any other value is invalid: the backend fails it during
ValidateManifest, so the plugin cannot be installed (it does not silently fall back towebview) - The plugin list API returns the value in the snake_case field
render_engine - The field can change between versions: to stop using Lynx, publish a new version that sets it back to
webview(or drops it)
When to declare lynx
Only declare it for plugins built with ReactLynx that have been tested end-to-end. Lynx is a completely different rendering path from WebView — the plugin UI is authored in ReactLynx, compiled to a .lynx.bundle, and loaded natively by the host via the Lynx <frame> element.
Prerequisites for declaring lynx:
- Use
pnpm create @songloft/songloft-pluginand select the "Lynx Native Rendering" template (or manually set up an rspeedy + ReactLynx environment) - Install
@songloft/lynx-plugin-sdkas the host communication SDK (replaces@songloft/client-sdkused in WebView) - Set
"staticHash": falseinplugin.json(prevents bundle files from being renamed)
Build output structure:
static/
├── main.lynx.bundle # Lynx native bundle (loaded by Lynx-capable clients)
├── main.web.bundle # Web fallback bundle (loaded by non-Lynx clients)
└── index.html # Auto-generated WebView fallback page (bootstraps web-core to load .web.bundle)Host communication: Lynx plugins do NOT use window.SongloftPlugin / @songloft/client-sdk (those are WebView-only). Instead, use @songloft/lynx-plugin-sdk's invokeHost / onPlayerState / onThemeChange APIs, which communicate via the NativeModules.SongloftPluginBridge native bridge.
Fallback mechanism: Clients that don't support Lynx (e.g. Flutter-based builds) open index.html, which bootstraps web-core to load .web.bundle in a WebView — functionally equivalent, just without native performance benefits.
4. Lifecycle
Plugins have three core lifecycle callback functions:
onInit()
Called after the plugin finishes loading. Used to initialize resources, set up timers, etc.
async function onInit() {
songloft.log.info("Plugin initialized");
await 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
remoteAddr: "192.168.1.20:54321" // TCP peer; never taken from forwarding headers; older hosts/internal calls may omit it
}The host supports the UPnP event subscription extension methods SUBSCRIBE and UNSUBSCRIBE. Dispatch them by req.method in onHTTPRequest, and add only protocol endpoints to publicPaths. Endpoints exempt from JWT must still validate their access scope and event callback addresses. OpenAPI 2 does not support these methods, so Swagger documents them in the catch-all description only.
To listen for SSDP multicast, use songloft.net.udpBind({address: "0.0.0.0:1900", reuseAddress: true}), followed by udpJoinMulticast(socketId, "239.255.255.250"). reuseAddress defaults to false and sets only SO_REUSEADDR; other sockets on that port must also allow reuse, or binding still fails. Older hosts ignore this option. Declare the net permission and close the socket when disabled. Active UDP sockets prevent idle eviction of the plugin.
Outbound TCP connections use songloft.net.tcpConnect(host, port, options?), returning a socket handle with send()/onData()/onClose()/close(), also requiring the net permission. Data reception reuses UDP's host-event push model. data is base64-encoded raw bytes (btoa on send, atob on onData): TCP is a byte stream, and a single read may split a multi-byte UTF-8 character; a raw string would be replaced with U+FFFD by json.Marshal and permanently corrupted, so plugins must accumulate bytes across chunks before decoding. Only private / loopback / link-local addresses are allowed (anti-SSRF); the 8-sockets-per-plugin quota is counted independently from UDP, and a plugin with active TCP connections is not idle-evicted. Typical use: controlling a local MPD (idle event push on port 6600).
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.
Important: all
songloft.*methods are asynchronous and return a Promise; you mustawaitthem inside anasyncfunction. This matches the behavior of standard Web APIs such asfetch. All examples below are placed in anasyncfunction context. Exceptions:songloft.log.*(synchronous local logging) andsongloft.comm.onMessage(...)(synchronous callback registration) do not needawait.
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 three runtime-internal control headers. They only affect runtime behavior and none of them are forwarded to the target server:
| Control header | Effect |
|---|---|
X-Fetch-No-Redirect | Disables automatic redirect following so the JS side handles the redirect chain manually (required to collect Set-Cookie from intermediate hops — Go's net/http swallows them) |
X-Fetch-Timeout-Ms | Sets the per-request timeout (100-30000ms) |
X-Fetch-Insecure | Skips TLS certificate verification. Requires the net:insecure-tls permission; without it the header is silently ignored and full verification is kept (the host logs a warning) |
X-Fetch-Insecureexists for self-hosted NAS-class devices: fnOS (port 5667), Synology and friends ship self-signed certificates by default, and plugins typically reach them by bare IP — even if the device installs a properly CA-signed certificate, the subject is a hostname, so connecting by IP always yields a hostname mismatch. There is no "just install a real certificate" escape hatch for these targets. Do not use it anywhere else: skipping verification means giving up MITM protection.
Response object fields:
ok—status >= 200 && status < 300status— HTTP status codestatusText— status textheaders— response headers object, see belowjson()— returnsPromise<unknown>, parses JSONtext()— returnsPromise<string>, raw text
Reading headers
headers supports both direct property access and the standard Headers accessor methods:
resp.headers['Content-Type'] // property style: keys are Go canonical form (Set-Cookie / Content-Type)
resp.headers.get('content-type') // method style: case-insensitive, multi-values joined with ", ", null when absent
resp.headers.has('set-cookie') // whether the header is present
resp.headers.getSetCookie() // raw array of every Set-Cookie (empty array when there is none)
resp.headers.forEach(function(value, name) { /* ... */ });For multiple cookies you must use
getSetCookie()— do not callget('set-cookie')and split on", "yourself. Joining multiple values is irreversible: a cookie's ownExpires=Wed, 21 Oct 2026 07:28:00 GMTattribute contains", ", so the split result cannot be told apart from the entry separator.getSetCookie()returns each entry intact.
These methods are non-enumerable, so Object.keys(resp.headers), for...in, and JSON.stringify(resp.headers) yield only the response headers themselves, never the method names.
In TypeScript, prefer
.get()over property access. The SDK declaresfetchas returning the standard DOMResponse, whoseheadersis typed asHeaders— which has no index signature. Soresp.headers['Content-Type']works at runtime but fails type checking, and can only be worked around withas unknown as Record<string, string>..get()is valid on both sides.
onHTTPRequest, onWebSocket, and event callbacks can all be async function; the framework waits for the Promise to settle.
songloft.net.httpStream — Cancellable HTTP body reads
Declare the net permission. httpStream returns a handle synchronously; response waits for headers, and read() returns at most 16 KiB of raw bytes or null at EOF. Only one read may be pending. Idempotent cancel() aborts connection setup and pending reads. Limits are eight requests per plugin, a 64 MiB response, and a 4 MiB string request body. timeoutMs covers connection and the entire body (100–30000 ms, default 30000).
Requests share fetch TLS, redirect, and internal control-header rules; timeoutMs overrides the internal timeout header. Headers preserve repeated values as Record<string, string[]>. EOF, errors, deadlines, unload, and sleep release requests; live requests prevent idle sleep. Consumer-driven reads avoid an unbounded body-event queue. Older hosts lack this method: detect it before calling. There is no SSE parsing or automatic reconnect; callers handle protocol framing and UTF-8 across chunks.
if (typeof songloft.net.httpStream === 'function') {
const request = songloft.net.httpStream('https://example.com/events', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stream: true }), timeoutMs: 30000,
});
try {
const { status, headers } = await request.response;
if (status < 200 || status >= 300) throw new Error(`HTTP ${status}`);
for (;;) {
const chunk = await request.read();
if (chunk === null) break;
// Feed bytes to an incremental protocol parser.
}
} finally {
await request.cancel();
}
}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: A background goroutine attempts to acquire the VM mutex with TryLock every 500ms and skips the round when busy. Timer callbacks share the VM with HTTP JavaScript; lengthy synchronous work can still hold the lock. The minimum interval for setInterval is capped at 10ms.
songloft.storage — Persistent Storage
Requires permission: storage
async function storageExample() {
// Read a value (asynchronously returns the original-typed value or null)
var value = await songloft.storage.get("key");
// Write a value (values are JSON-serialized automatically; objects/arrays can be stored directly)
await songloft.storage.set("config", { volume: 80, list: [1, 2, 3] });
// Delete a key
await songloft.storage.delete("key");
// Get all key names
var keys = await songloft.storage.keys(); // ["key1", "key2", ...]
}Storage limitations:
- Keys are strings
- Values are JSON-serialized automatically; you can store objects/arrays/numbers directly, and
getasynchronously returns the original-typed value or null - Each plugin has its own independent storage space
songloft.songs — Song Operations
Requires permission: songs.read
async function songsExample() {
// Get the song list
var songs = await songloft.songs.list({ limit: 20, offset: 0 });
// Get a song by ID
var song = await songloft.songs.getById(123);
// Search songs
var results = await 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_url: "", // Cover URL (the internal CoverPath field is never serialized)
is_video: false // Whether this is a video container
}songloft.playlists — Playlist Operations
Requires permission: playlists.read (read) or playlists.write (modify); or the wildcard sugar playlists.*.
async function playlistsExample() {
// Requires playlists.read
var playlists = await songloft.playlists.list();
var playlist = await songloft.playlists.getById(1);
var songs = await songloft.playlists.getSongs(1, { limit: 50, offset: 0 });
}songloft.tags — Tag Operations
Requires permission: tags.read (read) or tags.write (modify); or the wildcard sugar tags.*.
async function tagsExample() {
// —— Read operations (requires tags.read) ——
// Get tag list (supports filtering, sorting, pagination)
var tags = await songloft.tags.list({
keyword: "", // optional, fuzzy search by name
orderBy: "", // optional, sort field
order: "", // optional, "asc" | "desc"
limit: 100, // optional, default 100
offset: 0 // optional, default 0
});
// Get a single tag by ID
var tag = await songloft.tags.getById(1);
// Get all tags associated with a given song
var songTags = await songloft.tags.getSongTags(42);
// —— Write operations (requires tags.write) ——
// Create a tag
var newTag = await songloft.tags.create({ name: "My Tag", color: "#ff6600" });
// Update a tag
await songloft.tags.update({ id: 1, name: "New Name", color: "#00cc00" });
// Delete a tag
await songloft.tags.delete(1);
// Batch-bind songs to a tag (returns { bound: <actual count> })
var result = await songloft.tags.bindSongs({ tagId: 1, songIds: [10, 20, 30] });
// Batch-unbind songs (returns { unbound: <actual count> })
var result2 = await songloft.tags.unbindSongs({ tagId: 1, songIds: [10, 20] });
}Method reference:
| Method | Permission | Parameters | Returns |
|---|---|---|---|
list(options?) | tags.read | { keyword?, orderBy?, order?, limit?, offset? } | Tag[] |
getById(id) | tags.read | id: number | Tag |
getSongTags(songId) | tags.read | songId: number | Tag[] |
create(options) | tags.write | { name: string, color: string } | Tag |
update(options) | tags.write | { id: number, name: string, color: string } | void |
delete(id) | tags.write | id: number | void |
bindSongs(options) | tags.write | { tagId: number, songIds: number[] } | { bound: number } |
unbindSongs(options) | tags.write | { tagId: number, songIds: number[] } | { unbound: number } |
songloft.comm — Inter-Plugin Communication
Requires permission: inter-plugin
async function commExample() {
// Send a message (fire-and-forget)
await songloft.comm.send("target-plugin", "action-name", { data: "hello" });
// Request-response call (waits for a response, default timeout 10s)
var resp = await songloft.comm.call("target-plugin", "action-name", { data: "hello" }, 5000);
// resp = { success: true, data: { ... } }
}
// Register a message handler (registered synchronously, no await needed)
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.
async function pluginInfoExample() {
// Get the plugin's JWT Token (for accessing host APIs, such as authenticated resources like music files and covers)
var token = await songloft.plugin.getToken();
// Get the base URL of the host service (e.g. http://192.168.1.100:58091)
var hostUrl = await songloft.plugin.getHostUrl();
}Typical use: building an authenticated resource URL
async function getMusicUrl(songId) {
var host = await songloft.plugin.getHostUrl();
var token = await 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
songloft.lyrics — Lyrics Provider
No permission required.
A plugin can register as a lyrics provider. The host will automatically call it when a song has no lyrics.
Register / Unregister
// Register as a lyrics provider
songloft.lyrics.registerProvider();
// Unregister
songloft.lyrics.unregisterProvider();Implement the Search Endpoint
Once registered, the host calls the plugin's /lyric-search endpoint via InvokeHTTP. The plugin must implement this route.
Request parameters (Query String):
| Parameter | Type | Description |
|---|---|---|
title | string | Song title |
artist | string | Artist |
album | string | Album name |
duration | number | Duration in seconds |
fingerprint | string | Audio fingerprint (Chromaprint, optional, sent only when available) |
isrc | string | ISRC code (optional, sent only when available) |
Response format (HTTP 200 + JSON):
{
"lyric": "[00:01.00]First line\n[00:05.00]Second line",
"tlyric": "[00:01.00]Translation first line",
"rlyric": "[00:01.00]Romanized first line",
"lxlyric": "[00:01.00]Word-by-word lyrics"
}lyric(required): main lyrics in LRC formattlyric(optional): translated lyricsrlyric(optional): romanized lyricslxlyric(optional): word-by-word synced lyrics
Return HTTP 404 or empty body when no result is found.
Complete Example
/// <reference types="@songloft/plugin-sdk" />
import { createRouter, jsonResponse, parseQuery } from '@songloft/plugin-sdk';
const router = createRouter();
let registered = false;
router.get('/lyric-search', async (req: HTTPRequest) => {
const q = parseQuery(req.query);
const result = await searchFromMySource(
q.title, q.artist, q.album,
parseFloat(q.duration) || 0,
q.fingerprint, // optional, for exact matching
q.isrc // optional, for exact matching
);
if (!result) return jsonResponse(null, 404);
return jsonResponse(result); // { lyric, tlyric?, rlyric?, lxlyric? }
});
globalThis.onInit = async () => {
songloft.lyrics.registerProvider();
registered = true;
};
globalThis.onDeinit = async () => {
if (registered) songloft.lyrics.unregisterProvider();
};
globalThis.onHTTPRequest = (req: HTTPRequest) => router.handle(req);Workflow
- User plays a song with no lyrics; client requests
GET /api/v1/songs/{id}/lyric - Host finds lyrics empty, iterates over all registered lyrics providers
- Calls
GET /lyric-search?title=...&artist=...on each plugin (15s timeout) - First plugin returning HTTP 200 + non-empty lyrics wins; iteration stops
- Found lyrics are cached to the database asynchronously (
lyric_source=scraped); subsequent requests return the cache directly - For local songs, lyrics are also embedded into the audio file tags
songloft.covers — Cover Provider
No permission required.
A plugin can register as a cover provider. The host will automatically call it when a song has no cover artwork.
Register / Unregister
// Register as a cover provider
songloft.covers.registerProvider();
// Unregister
songloft.covers.unregisterProvider();Implement the Search Endpoint
Once registered, the host calls the plugin's /cover-search endpoint via InvokeHTTP. The plugin must implement this route.
Request parameters (Query String):
| Parameter | Type | Description |
|---|---|---|
title | string | Song title |
artist | string | Artist |
album | string | Album name |
fingerprint | string | Audio fingerprint (Chromaprint, optional, sent only when available) |
isrc | string | ISRC code (optional, sent only when available) |
Response format (HTTP 200 + JSON):
{
"cover_url": "https://example.com/covers/album.jpg"
}cover_url(required): full URL of the cover image
Return HTTP 404 or empty body when no result is found.
Complete Example
/// <reference types="@songloft/plugin-sdk" />
import { createRouter, jsonResponse, parseQuery } from '@songloft/plugin-sdk';
const router = createRouter();
let registered = false;
router.get('/cover-search', async (req: HTTPRequest) => {
const q = parseQuery(req.query);
const coverUrl = await searchCoverFromMySource(
q.title, q.artist, q.album,
q.fingerprint, // optional, for exact matching
q.isrc // optional, for exact matching
);
if (!coverUrl) return jsonResponse(null, 404);
return jsonResponse({ cover_url: coverUrl });
});
globalThis.onInit = async () => {
songloft.covers.registerProvider();
registered = true;
};
globalThis.onDeinit = async () => {
if (registered) songloft.covers.unregisterProvider();
};
globalThis.onHTTPRequest = (req: HTTPRequest) => router.handle(req);Workflow
- User plays a song with no cover; client requests
GET /api/v1/songs/{id}/cover - Host finds cover empty, iterates over all registered cover providers
- Calls
GET /cover-search?title=...&artist=...on each plugin (15s timeout) - First plugin returning HTTP 200 + non-empty
cover_urlwins; iteration stops - Found cover is persisted asynchronously:
- Local songs: downloads the cover image → saves to local
cover_path→ embeds into audio file tags - Remote songs: stores
cover_urlin the database
- Local songs: downloads the cover image → saves to local
- Subsequent requests return the cached result without calling plugins again
Provider Mechanism — Common Notes
Lyrics and cover providers share the same architecture:
- Multi-plugin support: multiple plugins can register as the same provider type; the host uses a first-match-wins strategy
- Idle-eviction safe: when a plugin is evicted from memory (idle cleanup), its provider registration is preserved; the host will auto-reload the plugin on next search
- Lazy cleanup: disabled or deleted plugins are automatically removed from the provider set during search iteration
- Fingerprint & ISRC: plugins are encouraged to prioritize
fingerprintandisrcfor exact matching (when available), then fall back to title/artist fuzzy search
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) |
tags.read | Read custom tags and song-tag associations |
tags.write | Create/modify/delete tags, bind/unbind songs |
tags.* | Tag 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 network sockets (UDP / outbound TCP) and cancellable HTTP body reads |
net:insecure-tls | Allow fetch to skip TLS certificate verification via X-Fetch-Insecure (self-signed / bare-IP access to self-hosted devices). Not covered by net; must be declared separately |
fs.* | File system wildcard (covers fs, fs:music, fs:external) |
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. Currently available wildcard sugars: songs.*, playlists.*, tags.*, fs.*.
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
async function notifyB() {
await 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
async function fetchFromB() {
var response = await 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 (design tokens/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 theme.css— MD3 color tokens (including light/dark dual themes), font declarations, CSS reset,bodybase styles, safe-area defaultscomponents.css— the shared component library aligned with the client's Flutter widgets (.card/.btn*/.switch/.tab-bar, etc.)common.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')
SongloftPlugin.getColorScheme() // Host's real color scheme {primary:'#415F91', ...}; null before push-downPlugin 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;Client SDK — Controlling the Host Player (webview pages only)
Plugin pages opened inside the Songloft client can call host client capabilities via window.SongloftPlugin.host / .player — most commonly to rewrite the host's "now playing queue".
- Works in: the native client (Android/iOS/macOS/Windows/Linux) webview plugin pages, and Web plugin pages (both embedded tab pages and home/full-screen pages open inside a host iframe, via a postMessage bridge).
- Does NOT work in: only when the user opens a plugin page standalone in a new browser tab via "Open in browser" (no host parent window) — there
host.isAvailable()returnsfalseand calls throw, so always feature-detect first.- Capabilities are injected by the host client and track its version. Set an appropriate
minHostVersioninplugin.jsonand usehost.getInfo().capabilitiesfor capability negotiation.
const { host, player } = SongloftPlugin;
if (host && host.isAvailable()) {
// Capability negotiation
const info = await host.getInfo(); // { version, platform, capabilities: ['player'] }
// Replace the now-playing queue with song ids and start from index 0
// (ids typically come from your own search results, persisted first via the
// server-side songs.create to obtain their ids)
await player.setQueue([101, 102, 103], { startIndex: 0 });
// Append to the end of the queue (without interrupting current playback)
await player.addToQueue([104]);
// Read state / subscribe to state changes
const state = await player.getState(); // { queue, current_index, is_playing, ... }
const off = player.onStateChange(s => console.log('now at index', s.current_index));
}player namespace methods: getState / setQueue / addToQueue / insertToQueue / removeFromQueue / reorderQueue / clearQueue / play(id?) / pause / togglePlay / next / prev / seek(seconds) / setVolume(0-100) / setPlayMode('order'|'loop'|'single'|'random'|'singlePlay') / playPlaylistById(id) / onStateChange(cb).
When developing with TypeScript / a bundler (e.g. the Vue template), install @songloft/client-sdk for full types and ergonomic wrappers:
import { player, host, isClient } from '@songloft/client-sdk';
if (isClient()) {
await player.setQueue([101, 102], { startIndex: 0 });
}Build-free vanilla static pages need no install — just use the injected window.SongloftPlugin.player directly (only losing type hints).
Favorite State Sync — Notify the Host After Changing Favorites
When a plugin changes favorites itself (e.g. POSTing /playlists/1/songs directly), the server data is correct, but the heart icons in the Flutter library read FavoriteNotifier's in-memory cache and will not follow along. Call favorite.refresh once afterwards:
const res = await SongloftPlugin.apiPost('/playlists/1/songs', { song_id: id });
await SongloftPlugin.favorite.refresh(id, res.is_favorited);Pass the arguments whenever you can: with (songId, isFavorited) the host performs an incremental update touching only that one song; without arguments it does a full reload, which is a complete round trip when the library holds thousands of songs.
Generic Host Calls via invokeHost
The player / host / favorite / getCookies namespaces above are all typed wrappers over invokeHost(ns, method, params?). The host's dispatch table lives on the client side and may be newer than the server's copy of common.js, so invokeHost is exposed as well, letting plugins reach namespaces no wrapper covers yet:
await SongloftPlugin.invokeHost('favorite', 'refresh', { songId: 42, isFavorited: true });⚠️ It lacks the type safety of a wrapper — a misspelled
ns/methodonly rejects at runtime. Prefer the wrapper whenever one exists.
Always use the public invokeHost.
Cookie Reading Bridge — Retrieve Third-Party Site Sessions (Native Only)
If a plugin needs session cookies from a third-party site (e.g., FN Connect gateway's os-access-code, self-hosted NAS login tokens, etc.), it can use the getCookies bridge — the host's native layer reads from the WebView Cookie Store, bypassing browser same-origin policy and HttpOnly restrictions.
// Prerequisite: user has opened the target site in the app's WebView and logged in
const cookies = await SongloftPlugin.getCookies('https://pcyear.5ddd.com');
// cookies: { 'os-access-code': 'xxx', 'music-token': 'yyy', ... }Parameters:
| Parameter | Type | Description |
|---|---|---|
origin | string | Target site origin, must include scheme+host(+port), e.g. https://example.com. Path is ignored |
Returns: Promise<Record<string, string>> — Cookie name→value map. Returns empty object {} if no cookies exist for that origin.
Platform Support:
| Platform | Supported | Notes |
|---|---|---|
| Android / iOS / macOS / Windows / Linux | ✅ | Native CookieManager reads WebView Cookie Store |
| Web | ❌ | Browser same-origin policy hard limit; calls will reject |
⚠️ Check platform before calling:
javascriptconst info = await SongloftPlugin.host.getInfo(); if (info.platform !== 'web') { const cookies = await SongloftPlugin.getCookies(origin); }
Typical workflow (FN Connect example):
- User adds a FN Music source, plugin constructs origin (e.g.
https://pcyear.5ddd.com) - Plugin guides user to open the target site in the app's WebView and log in
- After login, plugin calls
getCookies(origin)to retrieve session cookies - Stores cookies in plugin backend config (via
apiPostetc.), subsequent requests carry the session
TypeScript usage:
import { getCookies, host } from '@songloft/client-sdk';
const info = await host.getInfo();
if (info.platform !== 'web') {
const cookies = await getCookies('https://pcyear.5ddd.com');
await SongloftPlugin.apiPost('/config/cookies', cookies);
}Theme Adaptation
The main program's theme.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-container);
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> - Rewrite
--md-*with the real color scheme pushed down by the host (see below) - 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.
Glass appearance and accessibility
The optional host fields appearance.reduceTransparency / appearance.increaseContrast accept booleans only. common.js maps them to the <html> attributes data-reduce-transparency / data-increase-contrast ("true" or "false") and dispatches songloft-theme-appearance-change. Missing or invalid fields remove the attributes, meaning the host provided no information. These preferences are neither stored in localStorage nor restored from a previous host session. Plugins should OR host preferences with the browser's prefers-reduced-transparency / prefers-contrast, and provide an opaque background for reduced transparency, increased contrast, or unavailable blur.
The Lynx client sends the effective material and both flags, resending the full theme message when the material variant, theme pack, or accessibility preferences change. Flutter also sends effective preferences: reduced transparency is saved on the device, while increased contrast combines the local switch with Flutter's system high-contrast signal using OR. Native WebViews and Web iframes resend messages as the theme rebuilds. Older Flutter messages remain compatible with the missing-field rules above. --sl-theme-glass-fill is a host material color; it does not guarantee plugin text contrast over arbitrary backgrounds. Floating players should protect their own fill and text contrast, placing blur on a separate decorative layer so it does not change the containing block of fixed popups.
Public bridge regression test: node --test scripts/tests/plugin-theme-appearance.test.mjs.
Variable Reference
The --md-* variables map one-to-one onto Flutter's ColorScheme fields (camelCase → kebab-case), so the two sides can be audited against each other:
| Group | Variables |
|---|---|
| Primary | --md-primary --md-on-primary --md-primary-container --md-on-primary-container |
| Secondary | --md-secondary --md-on-secondary --md-secondary-container --md-on-secondary-container |
| Tertiary | --md-tertiary --md-on-tertiary --md-tertiary-container --md-on-tertiary-container |
| Error | --md-error --md-on-error --md-error-container --md-on-error-container |
| Surface | --md-surface --md-on-surface --md-on-surface-variant --md-surface-dim --md-surface-bright |
| Surface ladder | --md-surface-container-lowest --md-surface-container-low --md-surface-container --md-surface-container-high --md-surface-container-highest |
| Outline / inverse | --md-outline --md-outline-variant --md-inverse-surface --md-on-inverse-surface --md-inverse-primary |
| Songloft-specific (no M3 role, not pushed down) | --md-success --md-success-container --md-warning --md-warning-container |
| Derived alias (used by components: switch track / progress background) | --md-surface-variant→surfaceContainerHighest |
Corner radii (aligned with Flutter's AppRadius) | --md-radius-sm 8 · -md 12 · -lg 16 · -xl 24 · -xxl 28 · -full 50px |
| Shadows | --md-shadow-1 --md-shadow-2 --md-shadow-3 |
The old
--md-surface-1/--md-surface-2aliases have been removed — use--md-surface-container/--md-surface-container-highdirectly.
Don't invert surface and surface-container: --md-surface is the page background; cards, inputs and hover states step up through the container ladder. The shared .card is already the SectionCard form (outlined, no shadow, 16 radius), with the group label handled by .section-title (small uppercase text) outside the card — use them directly to match the client; you usually need no custom styles:
/* only needed when hand-drawing a card; the shared .card is already this form */
.my-section-card {
background: var(--md-surface-container);
border: 1px solid var(--md-outline-variant);
border-radius: var(--md-radius-lg);
}The Host Pushes Down the Real Color Scheme at Runtime
The static values in theme.css are only a first-frame fallback (derived from the default seed #415F91). Once the page is ready, the host pushes down the real ColorScheme — including the user's custom ThemePack — alongside the songloft-theme message, writing it as inline custom properties on documentElement. Inline styles win over everything, even a same-named variable the plugin redefines in its own :root.
So CSS-only plugins need no changes at all to match the main program's colors. Reading colors from JS, however, requires getColorScheme():
// Returns null before the host has pushed anything (the page is on static fallback colors)
const cs = SongloftPlugin.getColorScheme();
const primary = (cs && cs.primary) || '#415F91';
// Get notified on arrival / change. The event fires on document and does **not** bubble,
// so a listener on window will never see it.
document.addEventListener('songloft-color-scheme-change', e => {
console.log('new primary:', e.detail.colors.primary);
});⚠️ This is the reliable way to read a color value from JS —
getComputedStyle's behavior with CSS custom properties has no cross-environment contract. Do not rely on it for colors.The color scheme is guaranteed to land before
songloft-theme-changeis dispatched, so callinggetColorScheme()inside anonThemeChangecallback always yields the new values. Switching theme packs may leave light/dark unchanged while the colors change — onlysongloft-color-scheme-changefires in that case, so listen to both events for full coverage.
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
Joining GitHub Community Plugin Discovery
After publishing your plugin, add the songloft-plugin topic to its GitHub repository. On the repository home page, open About → settings gear → Topics, enter the topic and save. This is a repository topic, not a Release tag. Flutter and Lynx clients can automatically discover these repositories under Plugin Store → source menu → GitHub discovery, without waiting for manual listing.
Adding the topic alone does not guarantee visibility. Also check the following when publishing:
- The repository is public, not archived and not a fork, with a valid
plugin.jsonat the root of its default branch. - Publish a stable GitHub Release with an existing, nonempty
{entryPath}.jsplugin.zipasset. The manifest'sdownload_urlmust reference that asset, which may belong to another GitHub repository. Clients validate the actual target repository's Release and asset. IfupdateUrlsupplies the download URL, public metadata in another GitHub repository is also supported, with consistent versions and plugin entry paths. - Root manifest
entryHashandzipHashmay be omitted or empty without preventing GitHub discovery. Nonempty values must be 64 lowercase hexadecimal characters. The server still validates hashes in the actual installation package. Keep the root manifest'smainand version aligned with the package.zipHashis the toolchain's canonical content hash, not the SHA-256 of the ZIP file itself.
Release tags need not equal manifest versions: for example, v0.17 can accompany version: "0.17.0". The download URL must reference an actual Release asset. After adding the topic, refresh discovery to check the result; network failures appear as temporarily unverified. Community plugins are marked as not reviewed by Songloft and still require installation confirmation.
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 — callbacks and HTTP JavaScript access the VM serially, so avoid lengthy synchronous computation. The HTTP work for
fetchruns asynchronously in a Go goroutine; Promise continuations still require event-loop progress - 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 (storage serializes to JSON automatically; store the object directly)
async function saveConfig(config) {
await songloft.storage.set("config", config);
}
async function loadConfig() {
var config = await songloft.storage.get("config");
return config || { 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
async function useTranslation(text) {
var resp = await songloft.comm.call("translator-plugin", "get-service", {
method: "translate",
text: text
}, 5000);
if (resp.success && resp.data) {
return resp.data.text;
}
return text; // fallback
}13. Host Implementation Constraints (maintainers)
These constraints target maintainers changing the host's internal/jsplugin; plugin authors do not need them.
New permission sync checklist
A new permission constant must be added in three places, or plugins cannot use it at all:
AllPermissionsin the host'sinternal/jsplugin/permissions.goVALID_PERMISSIONSinplugins/toolchain/packages/plugin-builder/src/manifest.ts— this one hard-fails at build time (unknown permission: xxx), so missing it means the plugin cannot even produce an artifactAVAILABLE_PERMISSIONSinplugins/toolchain/packages/create-songloft-plugin/src/index.ts(the scaffold's checklist)
ValidatePermissions deliberately only warns on unknown permissions rather than rejecting: it sits on the mandatory install/update path, and rejecting would make a plugin declaring a new permission uninstallable on older hosts. minHostVersion is purely informational and never enforced; still wait for host adoption before shipping a plugin that needs the new permission.
Runtime projection of net:insecure-tls
The jsruntime layer has no concept of permissions, and fetch itself does not require net. After creating an environment, service.go calls jsManager.SetAllowInsecureTLS(envID, ...) to project the permission into JSEnv.allowInsecureTLS (default false) — that flag is its only runtime-layer manifestation.
CheckPermissiondoes no colon-prefix matching, sonetdoes not covernet:insecure-tls; otherwise every existing plugin that already declaresnetwould silently gain verification-skipping on upgrade- Use a dedicated
insecureTransport; do not reusesharedTransportand mutateTLSClientConfigper request, which would poison connection reuse for every plugin - Child environments (created via
songloft.jsenv) must inherit the parent plugin's policy - When the header is present without the permission, emit
slog.Warnrather than dropping it silently
The background pump must stay event-driven
Async chains detached from ExecuteJS (background loops started in onInit, work deferred with setTimeout(fn, 0)) advance only through JSService.runTimerProcessor: it wakes immediately on asyncSignal and sleeps until GetNextTimerDeadline, with 500ms only as a fallback. Restoring a fixed ticker makes every await continuation wait up to half a tick, silently costing seconds on chains with a dozen hops. executeInEnv must call signalAsync on return, or timers registered during that call stay invisible to an already-sleeping pump goroutine. When investigating plugin latency, align host-side doHTTPRequest timestamps with JS-side observations: a difference that lands on a fixed grid is the pump period.
Public asset versioning
jsplugin-assets/* uses Cache-Control: immutable long caching, so injected URLs must carry a content hash (?v=<hash>). New public assets must be registered in assetVersions in internal/jsplugin/routes.go; otherwise no later change will ever reach existing users. See Frontend Gotchas · cache layer.
Appendix: Complete Example
See the plugin-toolchain/examples/basic directory for complete example plugin code based on the official toolchain.
