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, tags.read, tags.write, inter-plugin, command, jsenv, fs, fs:music, fs:external, websocket, net, net:insecure-tls (wildcard sugars songs.*, playlists.*, tags.*, fs.* can be used in the manifest)
  3. Add-on feature templates (multi-select, skippable) — static pages (static/), executable management (bin/), Lynx native rendering (renderEngine: "lynx", ReactLynx + cross-platform native UI)
  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', 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;
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 mode — pnpm 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 upload — POST /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 [])
renderEnginestringNoEngine the client uses to render the plugin UI: webview / lynx; missing or empty string means webview. See renderEngine — Declaring the Rendering Engine
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

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.

json
{
  "entryPath": "my-plugin",
  "renderEngine": "lynx"
}
ValueMeaning
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 to webview)
  • 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-plugin and select the "Lynx Native Rendering" template (or manually set up an rspeedy + ReactLynx environment)
  • Install @songloft/lynx-plugin-sdk as the host communication SDK (replaces @songloft/client-sdk used in WebView)
  • Set "staticHash": false in plugin.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.

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

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

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.

Important: all songloft.* methods are asynchronous and return a Promise; you must await them inside an async function. This matches the behavior of standard Web APIs such as fetch. All examples below are placed in an async function context. Exceptions: songloft.log.* (synchronous local logging) and songloft.comm.onMessage(...) (synchronous callback registration) do not need await.

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 three runtime-internal control headers. They only affect runtime behavior and none of them are forwarded to the target server:

Control headerEffect
X-Fetch-No-RedirectDisables 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-MsSets the per-request timeout (100-30000ms)
X-Fetch-InsecureSkips 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-Insecure exists 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 < 300
  • status — HTTP status code
  • statusText — status text
  • headers — response headers object, see below
  • json() — returns Promise<unknown>, parses JSON
  • text() — returns Promise<string>, raw text

Reading headers

headers supports both direct property access and the standard Headers accessor methods:

javascript
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 call get('set-cookie') and split on ", " yourself. Joining multiple values is irreversible: a cookie's own Expires=Wed, 21 Oct 2026 07:28:00 GMT attribute 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 declares fetch as returning the standard DOM Response, whose headers is typed as Headers — which has no index signature. So resp.headers['Content-Type'] works at runtime but fails type checking, and can only be worked around with as 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.

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

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

javascript
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 get asynchronously returns the original-typed value or null
  • Each plugin has its own independent storage space

songloft.songs — Song Operations ​

Requires permission: songs.read

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

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_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.*.

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

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

MethodPermissionParametersReturns
list(options?)tags.read{ keyword?, orderBy?, order?, limit?, offset? }Tag[]
getById(id)tags.readid: numberTag
getSongTags(songId)tags.readsongId: numberTag[]
create(options)tags.write{ name: string, color: string }Tag
update(options)tags.write{ id: number, name: string, color: string }void
delete(id)tags.writeid: numbervoid
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

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

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

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

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

ParameterTypeDescription
titlestringSong title
artiststringArtist
albumstringAlbum name
durationnumberDuration in seconds
fingerprintstringAudio fingerprint (Chromaprint, optional, sent only when available)
isrcstringISRC code (optional, sent only when available)

Response format (HTTP 200 + JSON):

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 format
  • tlyric (optional): translated lyrics
  • rlyric (optional): romanized lyrics
  • lxlyric (optional): word-by-word synced lyrics

Return HTTP 404 or empty body when no result is found.

Complete Example ​

typescript
/// <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 ​

  1. User plays a song with no lyrics; client requests GET /api/v1/songs/{id}/lyric
  2. Host finds lyrics empty, iterates over all registered lyrics providers
  3. Calls GET /lyric-search?title=...&artist=... on each plugin (15s timeout)
  4. First plugin returning HTTP 200 + non-empty lyrics wins; iteration stops
  5. Found lyrics are cached to the database asynchronously (lyric_source=scraped); subsequent requests return the cache directly
  6. 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 ​

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

ParameterTypeDescription
titlestringSong title
artiststringArtist
albumstringAlbum name
fingerprintstringAudio fingerprint (Chromaprint, optional, sent only when available)
isrcstringISRC code (optional, sent only when available)

Response format (HTTP 200 + JSON):

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 ​

typescript
/// <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 ​

  1. User plays a song with no cover; client requests GET /api/v1/songs/{id}/cover
  2. Host finds cover empty, iterates over all registered cover providers
  3. Calls GET /cover-search?title=...&artist=... on each plugin (15s timeout)
  4. First plugin returning HTTP 200 + non-empty cover_url wins; iteration stops
  5. 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_url in the database
  6. 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 fingerprint and isrc for 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:

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)
tags.readRead custom tags and song-tag associations
tags.writeCreate/modify/delete tags, bind/unbind songs
tags.*Tag 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 network sockets (UDP / outbound TCP) and cancellable HTTP body reads
net:insecure-tlsAllow 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:

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

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

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 (design tokens/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. theme.css — MD3 color tokens (including light/dark dual themes), font declarations, CSS reset, body base styles, safe-area defaults
  4. components.css — the shared component library aligned with the client's Flutter widgets (.card/.btn*/.switch/.tab-bar, etc.)
  5. 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')
SongloftPlugin.getColorScheme()      // Host's real color scheme {primary:'#415F91', ...}; null before push-down

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;

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() returns false and calls throw, so always feature-detect first.
  • Capabilities are injected by the host client and track its version. Set an appropriate minHostVersion in plugin.json and use host.getInfo().capabilities for capability negotiation.
javascript
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:

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

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

js
await SongloftPlugin.invokeHost('favorite', 'refresh', { songId: 42, isFavorited: true });

⚠️ It lacks the type safety of a wrapper — a misspelled ns / method only rejects at runtime. Prefer the wrapper whenever one exists.

Always use the public invokeHost.

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.

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

ParameterTypeDescription
originstringTarget 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:

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

javascript
const info = await SongloftPlugin.host.getInfo();
if (info.platform !== 'web') {
  const cookies = await SongloftPlugin.getCookies(origin);
}

Typical workflow (FN Connect example):

  1. User adds a FN Music source, plugin constructs origin (e.g. https://pcyear.5ddd.com)
  2. Plugin guides user to open the target site in the app's WebView and log in
  3. After login, plugin calls getCookies(origin) to retrieve session cookies
  4. Stores cookies in plugin backend config (via apiPost etc.), subsequent requests carry the session

TypeScript usage:

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

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

  1. Update the data-theme attribute and the theme-light/theme-dark CSS classes on <html>
  2. Rewrite --md-* with the real color scheme pushed down by the host (see below)
  3. Dispatch a songloft-theme-change CustomEvent
  4. 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:

GroupVariables
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-2 aliases have been removed — use --md-surface-container / --md-surface-container-high directly.

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:

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

javascript
// 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-change is dispatched, so calling getColorScheme() inside an onThemeChange callback always yields the new values. Switching theme packs may leave light/dark unchanged while the colors change — only songloft-color-scheme-change fires 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:

  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.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.json at the root of its default branch.
  • Publish a stable GitHub Release with an existing, nonempty {entryPath}.jsplugin.zip asset. The manifest's download_url must reference that asset, which may belong to another GitHub repository. Clients validate the actual target repository's Release and asset. If updateUrl supplies the download URL, public metadata in another GitHub repository is also supported, with consistent versions and plugin entry paths.
  • Root manifest entryHash and zipHash may 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's main and version aligned with the package. zipHash is 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 ​

  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 blocking — onHTTPRequest should return quickly
  2. Use timers wisely — callbacks and HTTP JavaScript access the VM serially, so avoid lengthy synchronous computation. The HTTP work for fetch runs asynchronously in a Go goroutine; Promise continuations still require event-loop progress
  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 (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 ​

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

  1. AllPermissions in the host's internal/jsplugin/permissions.go
  2. VALID_PERMISSIONS in plugins/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 artifact
  3. AVAILABLE_PERMISSIONS in plugins/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.

  • CheckPermission does no colon-prefix matching, so net does not cover net:insecure-tls; otherwise every existing plugin that already declares net would silently gain verification-skipping on upgrade
  • Use a dedicated insecureTransport; do not reuse sharedTransport and mutate TLSClientConfig per 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.Warn rather 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.