Skip to content

Plugin Development Guide

Introduction

Komari supports extending the server with JavaScript plugins. A plugin is a ZIP package containing a manifest (komari-plugin.json) and an entry script (default script.js). Plugins run inside the server process in their own goja JavaScript runtime (sandbox), and can register HTTP routes, intercept HTTP requests and responses, call system RPC methods, register their own RPC methods, declare configuration items, and inject admin pages.

Security

Plugins inherit Komari's system privileges and may request sensitive capabilities such as filesystem access, child process execution, or port listening. Only install plugins you trust, and review the declared permissions carefully before enabling a third-party plugin.

Table of Contents

1. Quick Start

1.1 Using npm create komari-plugin

In addition to writing a plugin manually, you can create a project with the official scaffold:

sh
npm create komari-plugin

The template provides these common development features:

  • Plugin hot reload: npm run dev watches the source files and manifest, then automatically builds, packages, uploads, and re-enables the plugin. Changes are applied without repeating the installation manually.
  • Log tracking: the development command continuously prints plugin runtime logs, making load results and runtime errors easy to inspect.
  • Type hints: the template uses TypeScript and @komari-monitor/plugin-sdk, providing API types, parameter hints, and manifest field completion.
  • Local development configuration: the development server URL and API key are stored in komari.local.json, which is ignored by Git by default. Do not commit this file.

The template requires Node.js 20 or later, a reachable Komari development server, and an administrator API key.

The generated project looks like this:

text
hello/
├── src/plugin.ts          # TypeScript plugin source
├── komari-plugin.json     # Plugin manifest
├── komari.local.json      # Local server URL and API key; do not commit
├── package.json
└── tsconfig.json

SDK example:

ts
import { definePlugin, jsonResponse, server } from "@komari-monitor/plugin-sdk";

definePlugin({
  load() {
    server.route("GET", "/hello", (_req, res) => {
      jsonResponse(res, { ok: true });
    });
  },
});

1.2 Writing a Minimal Plugin Manually

A plugin package is a ZIP file. Its root must contain komari-plugin.json, and the default entry script is script.js:

text
my-plugin.zip
├── komari-plugin.json
└── script.js

komari-plugin.json:

json
{
  "name": "Hello Plugin",
  "short": "hello-plugin",
  "description": "Minimal Komari plugin",
  "author": "Example",
  "version": "1.0.0",
  "entry": "script.js",
  "permissions": {
    "timeout": 30
  }
}

script.js:

js
const server = require("server");

function load() {
  console.log("hello plugin loaded");
}

function unload() {
  console.log("hello plugin unloaded");
}

2. Plugin Package and Manifest

2.1 Package Structure

text
<plugin>.zip
├── komari-plugin.json            # Required and must be in the ZIP root
├── script.js                     # Default entry
├── pages/                        # Optional iframe pages and assets
│   └── admin.html
└── assets/

Archive limits:

LimitCurrent value
Maximum number of files10000
Maximum uncompressed file size128 MiB
Maximum total uncompressed size512 MiB
Maximum manifest size1 MiB

2.2 komari-plugin.json Fields

FieldTypeRequiredDefaultDescription
namestring | Record<string,string>Yes-Plugin name. Accepts a plain string or an i18n object such as {"zh_CN":"Example","en":"Example"}.
shortstringYes-Plugin short name. Only letters, digits, _, and - are allowed. It cannot be default and is also used as the directory name.
descriptionstring | Record<string,string>No-Plugin description.
authorstring | Record<string,string>No-Author.
versionstringNo-Plugin version. Market installation compares it with the catalog version.
urlstringNo-Project homepage or repository URL.
iconstringNo-Plugin icon. Must be a relative path inside the plugin directory.
komaristringNo-Komari version constraint, such as >=0.0.1 or 1.2.3.
entrystringNoscript.jsEntry script. Must be a relative path inside the plugin directory.
permissionsPluginPermissionsNoZero valuesRuntime permissions and limits.
configurationConfigurationNo-Configuration item declarations, using the same shape as themes.
pagesPluginPage[]No-Plugin page declarations.

Supported komari constraints:

SyntaxMeaning
Empty stringNo restriction
x.y.zExact match
>=x.y.zGreater than or equal to
>x.y.zGreater than
<=x.y.zLess than or equal to
<x.y.zLess than

The version may have a leading v and contains at most three numeric components.

2.3 permissions Fields

FieldTypeDefaultRequires approvalDescription
nodebooleanfalseNoEnables the Node.js compatibility modules.
allowSystemRPCbooleanfalseYesAllows server.call() to invoke system RPC methods with administrator authority.
allowRoutesbooleanfalseYesAllows server.route() and server.static().
allowHooksbooleanfalseYesAllows HTTP and WebSocket hooks.
allowHTMLInjectbooleanfalseYesAllows server.injectHTML() to inject fragments into HTML responses.
allowExecbooleanfalseYesAllows child_process to execute child processes.
allowListenbooleanfalseYesAllows net and http servers to listen on local ports.
allowAllFileAccessbooleanfalseYesAllows access to files outside the plugin directory.
maxHTTPBodyBytesinteger33554432NoBuffer limit for fetch response bodies, HTTP server request bodies, and route request bodies.
maxChildOutputBytesinteger1048576NoBuffer limit for each stdout or stderr stream from a child process.
timeoutinteger30NoPer-turn execution timeout in seconds.

Changing permissions invalidates the stored approval hash, so the plugin must be approved again before it can be enabled.

2.4 configuration Field

See Managed Configuration.

To read the saved managed configuration, call server.getConfig().

Example:

json
{
  "type": "managed",
  "data": [
    {
      "key": "endpoint",
      "name": "Endpoint",
      "required": true,
      "type": "string",
      "default": "https://example.com"
    },
    {
      "key": "enabled",
      "name": "Enabled",
      "type": "switch",
      "default": true
    }
  ]
}

2.5 pages Field

FieldTypeRequiredDefaultDescription
filestringRequired for iframe-Relative HTML file inside the plugin directory. Not used by redirect.
titlestring | Record<string,string>Yes-Page title.
iconstringNo-Page icon. Relative path inside the plugin directory.
type"iframe" | "redirect"No"iframe"How the page is presented.
urlstringRequired for redirect-Internal absolute path. It must start with / and cannot contain //, a backslash, or ...
visibility"admin" | "public"No"admin"Page access scope.

visibility: "public" only applies to iframe pages. Public pages are served by /api/plugin/:short/*filepath without authentication, and only files in the directory of the declared public page and its subdirectories are accessible.

See Plugin Pages for details.

2.6 Complete Manifest Example

json
{
  "name": {
    "zh_CN": "Status Extension",
    "en": "Status Extension"
  },
  "short": "status-extension",
  "description": "Adds a status endpoint and an admin page",
  "author": "Example",
  "version": "1.2.0",
  "url": "https://example.com/status-extension",
  "icon": "assets/icon.png",
  "komari": ">=0.0.1",
  "entry": "script.js",
  "permissions": {
    "node": true,
    "allowRoutes": true,
    "allowHooks": true,
    "allowHTMLInject": true,
    "allowSystemRPC": true,
    "timeout": 30,
    "maxHTTPBodyBytes": 33554432
  },
  "configuration": {
    "type": "managed",
    "data": [
      {
        "key": "message",
        "name": "Message",
        "type": "string",
        "default": "hello"
      }
    ]
  },
  "pages": [
    {
      "file": "pages/admin.html",
      "title": "Status",
      "icon": "assets/icon.png",
      "type": "iframe",
      "visibility": "admin"
    },
    {
      "file": "pages/public.html",
      "title": "Public status",
      "type": "iframe",
      "visibility": "public"
    }
  ]
}

3. Lifecycle Interfaces

The plugin entry script is not a CommonJS wrapper. Its top-level code runs directly. The runtime recognizes only the optional global functions load and unload.

3.1 load()

Description: Called once after the plugin is loaded. Use it to register routes, hooks, HTML injections, cron jobs, and RPC methods, and to initialize plugin resources.

Parameters: None.

Return value: No value is required. It can return undefined or a Promise. When it returns a Promise, the runtime waits for it to settle.

Caller: The Komari plugin manager.

Example:

js
const server = require("server");

function load() {
  console.log("plugin loaded");
  server.registerRPC("plugin:hello", () => ({ ok: true }));
}

Async example:

js
const server = require("server");

async function load() {
  await new Promise((resolve) => setTimeout(resolve, 100));
  server.registerRPC("plugin:hello", () => ({ ok: true }));
}

If load() throws or its Promise rejects, plugin loading fails, the plugin is disabled automatically, and the error is stored as last_error.

3.2 unload()

Description: Called once before the plugin is unloaded. Use it to release resources created by the plugin. Komari automatically unregisters the plugin's hooks, HTML injections, cron jobs, and RPC methods.

Parameters: None.

Return value: No value is required. It can return undefined or a Promise. When it returns a Promise, the runtime waits for it to settle.

Caller: The Komari plugin manager.

Example:

js
let timer = null;

function load() {
  timer = setInterval(() => console.log("tick"), 1000);
}

async function unload() {
  if (timer !== null) {
    clearInterval(timer);
    timer = null;
  }
}

If unload() throws or its Promise rejects, the error is reported, but the manager still completes its cleanup.

4. JavaScript Runtime and Compatibility Modules

Plugins run in an isolated goja JavaScript runtime with CommonJS require(), Promise and async/await support, an event loop, and common web APIs. It is not a browser and not a complete Node.js implementation. A same-named API does not necessarily have the same edge-case behavior as a browser or Node.js.

The following are available without any additional permission:

CategoryAPIs
Base interfacesconsole, setTimeout / setInterval / setImmediate and their clear functions, queueMicrotask
HTTP clientsfetch, XMLHttpRequest
Always-available modulesbuffer, url, util
File accessThe plugin code directory and data/plugin-data/<short>. The latter is available as __storageDir__ when node: true.

When permissions.node is true, the runtime also injects Buffer, process, global, __dirname, and __filename, and provides these Node.js compatibility modules:

ModuleDescription
events, stream, path, os, processEvents, streams, paths, host information, and process interfaces.
fsFile access. It is restricted to the plugin code directory and __storageDir__ by default; escaping those roots requires allowAllFileAccess.
child_processChild process execution. Requires allowExec.
net, httpTCP and HTTP. Server listening requires allowListen and binds to 127.0.0.1 by default.
cryptoHashes, random values, key derivation, AES and ChaCha20-Poly1305, and common signing and verification operations.

The runtime does not provide the browser DOM, WebSocket, EventSource, Web Streams, ESM import or export, or complete implementations of Node core modules such as https, tls, dns, zlib, and worker_threads. Metrics from process.memoryUsage() and cpuUsage() describe the whole Komari process, not an individual plugin. For complete compatibility boundaries, see pkg/jsruntime/README.md in the Komari repository.

5. server Module

js
const server = require("server");

5.1 server.route(method, path, handler)

Description: Registers a route on the Komari HTTP engine.

Permission: allowRoutes.

Parameters:

ParameterTypeRequiredDescription
methodstringYesHTTP method. It is converted to uppercase and cannot be empty.
pathstringYesRoute path. It must start with /.
handler(req, res) => void | Promise<void>YesRequest handler. It may return a Promise but must eventually call res.end().

Return value: undefined.

req object:

FieldTypeDescription
methodstringRequest method.
urlstringRequest URI, including the query string.
headersRecord<string,string | string[]>Request headers with lowercase keys.
queryRecord<string,string>Query parameters. Multiple values with the same name are joined with commas.
bodystringRequest body text.
contextRequestContextCaller and network information.

req.context object:

FieldTypeDescription
principalobjectThe resolved caller identity.
principal.type"anonymous" | "agent" | "user" | "api_key"Identity type.
principal.rolesstring[]Role list.
principal.user_uuidstringUser UUID, or an empty string when absent.
principal.client_uuidstringClient UUID, or an empty string when absent.
principal.is_api_keybooleanWhether the request uses an API key.
rolestringCurrent request role. Omitted when absent.
user_uuidstringCurrent user UUID. Omitted when absent.
client_uuidstringCurrent client UUID. Omitted when absent.
remote_ipstringRemote IP address.
user_agentstringUser-Agent header.

res object:

MemberTypeDescription
statusCodenumberResponse status code, default 200.
statusMessagestringStatus text field. It is currently exposed only as a writable field.
streamingbooleanWhen true, write() pushes data immediately.
setHeader(name, value)(string, string | string[]) => resSets a response header.
getHeader(name)(string) => string | string[] | undefinedReads a response header.
removeHeader(name)(string) => voidRemoves a response header.
write(data)(string | Buffer | ArrayBuffer | Uint8Array) => booleanWrites response data. In streaming mode it sends data immediately.
end(data?)(string?) => resEnds the response and optionally appends text.
isAborted()() => booleanReports whether the client disconnected or the stream was aborted.

Request example:

js
const server = require("server");

function load() {
  server.route("GET", "/status", (req, res) => {
    res.setHeader("Content-Type", "application/json; charset=utf-8");
    res.end(JSON.stringify({
      message: "ok",
      viewer: req.context.principal.type,
      query: req.query
    }));
  });
}

Async and error handling example:

js
const server = require("server");

function load() {
  server.route("POST", "/echo", async (req, res) => {
    try {
      await new Promise((resolve) => setTimeout(resolve, 10));
      res.setHeader("Content-Type", "application/json");
      res.statusCode = 201;
      res.end(JSON.stringify({ body: req.body }));
    } catch (error) {
      res.statusCode = 500;
      res.end(error.message);
    }
  });
}

Streaming example:

js
const server = require("server");

function load() {
  server.route("GET", "/stream", (req, res) => {
    res.streaming = true;
    res.setHeader("Content-Type", "text/plain; charset=utf-8");
    let count = 0;
    const timer = setInterval(() => {
      if (res.isAborted() || count >= 5) {
        clearInterval(timer);
        res.end();
        return;
      }
      res.write(`chunk-${count++}\n`);
    }, 100);
  });
}

Route slots remain after the plugin is unloaded, but requests return 404. A non-streaming handler times out after permissions.timeout seconds and returns 504.

5.2 server.static(mount, dir, options?)

Description: Mounts a static directory from the plugin directory on an HTTP path.

Permission: allowRoutes.

Parameters:

ParameterTypeRequiredDescription
mountstringYesMount path. It must start with / and cannot be /.
dirstringYesRelative directory path inside the plugin directory.
optionsobjectNo{ spa?: boolean }. With spa: true, unmatched paths fall back to index.html.

Return value: undefined.

Static file behavior:

  • Registers both GET and HEAD.
  • The mount root resolves to index.html.
  • A subdirectory resolves to that directory's index.html.
  • In non-SPA mode, a missing file returns 404.
  • In SPA mode, failed resolution falls back to the mount directory's index.html.

Example:

js
const server = require("server");

function load() {
  server.static("/panel", "dist", { spa: true });
}

In this example, files are served from data/plugin/<short>/dist.

5.3 server.call(method, params?)

Description: Calls a registered Komari RPC method with administrator authority.

Permission: allowSystemRPC.

Parameters:

ParameterTypeRequiredDescription
methodstringYesRPC method name, such as common:getVersion.
paramsanyNoRPC parameters. A single value is passed directly; multiple values are passed positionally.

Return value: Promise<any>. It resolves to the RPC result. On failure it rejects with an Error carrying code, message, and optional data.

Basic calls:

js
const version = await server.call("common:getVersion");
const result = await server.call("plugin:echo", { text: "hello" });

Example:

js
const server = require("server");

function load() {
  server.route("GET", "/version", async (req, res) => {
    try {
      const version = await server.call("common:getVersion");
      res.setHeader("Content-Type", "application/json");
      res.end(JSON.stringify(version));
    } catch (error) {
      res.statusCode = 502;
      res.end(JSON.stringify({
        code: error.code,
        message: error.message,
        data: error.data
      }));
    }
  });
}

For the parameters, return values, and errors of individual RPC methods, see the RPC documentation.

5.4 server.hook(kind, fn) / server.hook(kind, matcher, fn)

Description: Registers HTTP request or response hooks, or WebSocket connection and frame hooks.

Permission: allowHooks.

Parameters:

ParameterTypeRequiredDescription
kindstringYesrequest, response, wsConnect, wsMessage, wsSend, or wsClose.
matcherstringNoHTTP hooks support "METHOD /path", "/path", and "/path/*". WebSocket hooks accept only path matchers.
fnfunctionYesHook callback.

Return value: undefined.

Callback signatures:

kindCallback signatureReturn value
request(req) => voidNone
response(req, res) => voidNone
wsConnect(ctx) => object | void{ deny?: boolean, reason?: string }
wsMessage(ctx, msg) => object | void{ type?: number, data?: any, drop?: boolean }
wsSend(ctx, msg) => object | void{ type?: number, data?: any, drop?: boolean }
wsClose(ctx) => voidNone

Example:

js
const server = require("server");

function load() {
  server.hook("request", "/api/*", (req) => {
    req.headers["x-plugin"] = "status-extension";
  });

  server.hook("response", "GET /api/version", (req, res) => {
    res.headers["x-version-hooked"] = "1";
  });
}

The HTTP hook matcher, execution order, and error behavior, as well as the WebSocket hook context and frame handling rules, are described below.

5.4.1 request Hook

Description: Modifies a request before the business handler runs.

Registration: server.hook("request", fn) or server.hook("request", matcher, fn).

Parameters:

ParameterTypeDescription
reqRequestMutable request object.

Request fields:

FieldTypeWritableDescription
methodstringYesRequest method.
urlstringYesRequest URI, including the query string.
headersRecord<string,string | string[]>YesRequest headers with lowercase keys.
queryRecord<string,string>NoParsed query parameters. Changing this object does not write back to the URL.
bodystringYesRequest body text.
contextHookContextNoNetwork information.

HookContext fields:

FieldTypeDescription
remote_ipstringUses the first X-Forwarded-For entry, then X-Real-IP, then the host from RemoteAddr.
user_agentstringUser-Agent header.

Return value: None. Direct changes to req.method, req.url, req.headers, and req.body take effect.

Example:

js
const server = require("server");

function load() {
  server.hook("request", "POST /api/items/*", (req) => {
    req.headers["x-plugin-request"] = "1";
    req.url = req.url.replace("old=1", "old=0");
    req.body = req.body.replaceAll("foo", "bar");
  });
}

When a request matches the matcher, the request body is read and buffered. Requests that do not match skip both the hook and buffering. If the request body exceeds permissions.maxHTTPBodyBytes, the server returns 413.

5.4.2 response Hook

Description: Modifies response status, headers, and body after the business handler returns.

Registration: server.hook("response", fn) or server.hook("response", matcher, fn).

Parameters:

ParameterTypeDescription
reqRequestSame request object as in 5.4.1. In a response hook, body is "".
resResponseMutable response object.

Response fields:

FieldTypeWritableDescription
statusCodenumberYesHTTP status code.
statusMessagestringYesCurrently always an empty string.
headersRecord<string,string | string[]>YesResponse headers with lowercase keys.
bodystringYesResponse body text.

Return value: None. Direct changes to res take effect.

Example:

js
const server = require("server");

function load() {
  server.hook("response", "/api/version", (req, res) => {
    res.statusCode = 200;
    res.headers["x-plugin-response"] = "1";
    res.body = res.body.replace("Komari", "Komari + Plugin");
  });
}

If the body changes, the original Content-Length is removed. Once the internal response buffer limit is reached, the response passes through directly and response hooks can no longer rewrite it. A streaming response enters pass-through mode after the first Flush(), after which hooks no longer rewrite it.

5.4.3 Hook Matcher

Formats:

text
"METHOD /path"
"/path"
"/path/*"

Rules:

RuleDescription
Matcher omittedMatches every request.
"METHOD /path"Matches the specified HTTP method and exact path.
"/path"Matches the exact path for any HTTP method.
"/path/*"Matches /path and every descendant path.
"/*"Matches every path.

Path comparison is case-insensitive. HTTP methods must be GET, HEAD, POST, PUT, PATCH, DELETE, or OPTIONS. If the method in "METHOD..." is not recognized, the whole string is treated as a path and fails because it does not start with /.

5.4.4 HTTP Hook Execution and Errors

  • Multiple request hooks run in registration order, and each hook sees the request modifications made by the previous hook.
  • Multiple response hooks run in registration order, and each hook sees the response modifications made by the previous hook.
  • When a request hook reaches timeout, the request returns 500. When a response hook reaches timeout, the error is logged and the original response is sent. Either hook may continue running after the timeout, and later side effects are not guaranteed to be rolled back.
  • If a request hook throws, the request returns 500 plugin request hook failed. If a response hook throws, the error is logged and the original response is sent.
  • WebSocket upgrade requests bypass request and response hooks and use the connection and frame hooks beginning in 5.4.5.

5.4.5 WebSocket Hooks

WebSocket hooks share the allowHooks permission with HTTP hooks. Every upgrade is a GET request, so WebSocket matchers accept only paths:

js
server.hook("wsMessage", "/api/clients/v2/rpc", (ctx, msg) => {
  return { data: "replaced" };
});

5.4.6 Common ctx Object

FieldTypeDescription
pathstringWebSocket endpoint path.
connIdnumberConnection ID.
remoteIpstringRemote IP address.
userAgentstringUser-Agent header.
clientUuidstringClient UUID. Omitted when empty.

5.4.7 wsConnect

Callback: (ctx) => object | void

Return value:

FieldTypeDescription
denybooleanWhen true, reject the connection.
reasonstringRejection reason.

Example:

js
const server = require("server");

function load() {
  server.hook("wsConnect", "/api/clients/v2/rpc", (ctx) => {
    if (ctx.remoteIp === "203.0.113.9") {
      return {
        deny: true,
        reason: "blocked by plugin"
      };
    }
  });
}

The first hook returning deny: true rejects the connection. Returning undefined, null, or an object without deny allows the connection.

5.4.8 wsMessage

Callback: (ctx, msg) => object | void

Inbound msg:

FieldTypeDescription
typenumberWebSocket frame type.
datastring | ArrayBufferString for a text frame, ArrayBuffer for a binary frame.
connIdnumberConnection ID.
pathstringEndpoint path.

Return value:

FieldTypeDescription
typenumberReplacement frame type.
datastring | ArrayBuffer | Buffer | Uint8ArrayReplacement frame payload.
dropbooleanWhen true, drop the frame. This takes precedence over type and data.

Example:

js
const server = require("server");

function load() {
  server.hook("wsMessage", "/api/rpc2", (ctx, msg) => {
    if (typeof msg.data === "string" && msg.data.includes('"blocked"')) {
      return { drop: true };
    }
    return { data: msg.data };
  });
}

Multiple wsMessage hooks run in registration order as a chain. Each hook sees the frame produced by the previous hook. Oversized frames pass through directly.

5.4.9 wsSend

Callback: (ctx, msg) => object | void

Its parameters, return shape, and chaining rules are the same as wsMessage, but it applies to frames sent from the server to the client.

Example:

js
const server = require("server");

function load() {
  server.hook("wsSend", "/api/clients/v2/rpc", (ctx, msg) => {
    if (typeof msg.data === "string") {
      return { data: msg.data.replace("old", "new") };
    }
  });
}

5.4.10 wsClose

Callback: (ctx) => void

Called once when the connection ends. Its return value is ignored.

Example:

js
const server = require("server");

function load() {
  server.hook("wsClose", "/api/clients/v2/rpc", (ctx) => {
    console.log("websocket closed: " + ctx.connId);
  });
}

5.4.11 WebSocket Hook Limits

  • wsMessage and wsSend allow at most 8 MiB per frame. Larger frames skip every hook and pass through unchanged.
  • Each frame-level hook waits at most one second. After a timeout, the event is logged and the frame passes through unchanged.
  • Connection-level hooks use permissions.timeout.
  • After 16 consecutive dropped frames, the read loop ends with an error.
  • If the runtime closes, a hook times out, or a hook throws, the connection or frame continues with safe pass-through behavior.

5.5 server.injectHTML(head, body)

Description: Injects HTML fragments into every text/html response. head is inserted before </head>, and body is inserted before </body>.

Permission: allowHTMLInject.

Parameters:

ParameterTypeRequiredDescription
headstringYesFragment inserted before </head>. Pass an empty string when unused.
bodystringYesFragment inserted before </body>. Pass an empty string when unused.

Return value: undefined.

Example:

js
const server = require("server");

function load() {
  server.injectHTML(
    '<link rel="stylesheet" href="/api/plugin/status-extension/assets/panel.css">',
    '<script src="/api/plugin/status-extension/assets/panel.js"></script>'
  );
}

Injection applies to every HTML page, including admin and terminal pages. Responses larger than the internal HTML buffer limit are not injected.

5.6 server.cron(expr, fn)

Description: Runs a callback on the plugin event loop according to a cron expression.

Permission: Granted by default; no manifest declaration is required.

Parameters:

ParameterTypeRequiredDescription
exprstringYesA 5-field or 6-field cron expression, or @every <duration>.
fn() => voidYesScheduled callback. Its return value does not affect scheduling.

Return value: undefined.

Expression formats:

FormatFields
5 fieldsminute hour day-of-month month day-of-week
6 fieldssecond minute hour day-of-month month day-of-week
Interval@every 30s, @every 1m, @every 1h

Fields support *, */n, a-b, a-b/n, comma-separated lists, and specific numbers. In day-of-week, 7 is equivalent to 0.

Example:

js
const server = require("server");

function load() {
  server.cron("*/5 * * * *", () => {
    console.log("every five minutes");
  });

  server.cron("@every 30s", () => {
    console.log("every thirty seconds");
  });
}

Cron jobs registered by a plugin are cancelled when the plugin is unloaded.

5.7 server.registerRPC(method, handler)

Description: Registers a plugin-owned RPC method.

Permission: Granted by default; no manifest declaration is required.

Parameters:

ParameterTypeRequiredDescription
methodstringYesRPC method name. It cannot be empty or start with rpc..
handler(params) => anyYesRPC handler. It receives the raw params and returns a synchronously JSON-serializable value.

Return value: undefined.

Example:

js
const server = require("server");

function load() {
  server.registerRPC("plugin:statusEcho", (params) => {
    return {
      echo: params,
      at: new Date().toISOString()
    };
  });
}

A normal Error thrown by the handler becomes JSON-RPC -32603. To return a business error code, attach code and data to the Error:

js
const server = require("server");

function load() {
  server.registerRPC("plugin:statusFail", () => {
    const error = new Error("status unavailable");
    error.code = -32051;
    error.data = { retryable: true };
    throw error;
  });
}

See section 10 for the invocation entry point and permission rules.

5.8 server.getConfig()

Description: Reads the saved plugin configuration and merges it with defaults declared in the manifest.

Permission: Granted by default; no manifest declaration is required.

Parameters: None.

Return value: Promise<Record<string, any>>.

Example:

js
const server = require("server");

function load() {
  server.route("GET", "/config", async (req, res) => {
    const config = await server.getConfig();
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify(config));
  });
}

For example, if the configuration contains message and enabled, the return shape is:

json
{
  "message": "hello",
  "enabled": true
}

6. Plugin Pages

6.1 Admin iframe Pages

Description: An iframe page with visibility: "admin" is loaded by the admin navigation.

File route: GET /api/admin/plugin/:short/*filepath

Authentication: Administrator.

Path parameters:

ParameterTypeDescription
shortstringPlugin short name.
filepathstringFile path inside the plugin directory.

Return value: File contents. Returns 404 if the file is missing, the path is invalid, or the plugin is not installed.

Example:

bash
curl -s "$BASE/api/admin/plugin/status-extension/pages/admin.html" \
  -H "Cookie: $COOKIE"

Resources from the same origin in pages/admin.html can use the same prefix:

html
<link rel="stylesheet" href="/api/admin/plugin/status-extension/pages/admin.css">
<script src="/api/admin/plugin/status-extension/pages/admin.js"></script>

6.2 Public iframe Pages

Description: A page with visibility: "public" and type: "iframe" can be accessed without authentication.

File route: GET /api/plugin/:short/*filepath

Authentication: None.

Path parameters:

ParameterTypeDescription
shortstringPlugin short name.
filepathstringFile path inside the directory of the public page.

Return value: File contents. Returns 404 if the plugin is disabled, the file is outside the public page directory, or the path is invalid.

Example:

bash
curl -s "http://127.0.0.1:8080/api/plugin/status-extension/pages/public.html"

If the public page is pages/public.html, relative resources such as pages/public.js and pages/public.css in the same directory are also accessible. Files outside that directory, such as script.js or an admin page, and paths containing ../ are not accessible.

6.3 redirect Pages

A page with type: "redirect" is not served from the plugin's static file route. The admin UI navigates to the internal path specified by url. The URL must start with /, must not start with //, and cannot contain backslashes, a URL scheme, or a .. path segment.

Example:

json
{
  "title": "Open dashboard",
  "type": "redirect",
  "url": "/admin/dashboard",
  "visibility": "admin"
}

7. Plugin Configuration

7.1 Reading Configuration in a Plugin

Use server.getConfig() inside the plugin. See 5.8. The result merges saved values with manifest defaults.

7.2 Reading Configuration Declarations and Values as an Administrator

Interface: GET /api/admin/plugin/configuration?short=<short>

Authentication: Administrator.

Query parameters:

ParameterTypeRequiredDescription
shortstringYesPlugin short name.

Return value: Standard envelope. data.configuration is the manifest configuration declaration, and data.data is the resolved saved values.

Example:

bash
curl -s "$BASE/api/admin/plugin/configuration?short=status-extension" \
  -H "Cookie: $COOKIE"

Example response:

json
{
  "status": "success",
  "data": {
    "configuration": {
      "type": "managed",
      "data": [
        {
          "key": "message",
          "name": "Message",
          "type": "string",
          "default": "hello"
        }
      ]
    },
    "data": {
      "message": "hello"
    }
  }
}

7.3 Saving Configuration as an Administrator

Interface: POST /api/admin/plugin/configuration

Authentication: Administrator.

Request body:

FieldTypeRequiredDescription
shortstringYesPlugin short name.
dataobjectNoComplete configuration value object. When omitted or null, an empty object is saved.

Return value: Standard success envelope. If the plugin is enabled, it is reloaded immediately after saving.

Example:

bash
curl -s -X POST "$BASE/api/admin/plugin/configuration" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "short": "status-extension",
    "data": {
      "message": "from-admin",
      "enabled": true
    }
  }'

If saving succeeds but the reload fails, the configuration has already been written and the call returns an error containing plugin configuration saved but reload failed.

8. Plugin-owned RPC

8.1 Registration

Plugins register methods through server.registerRPC(method, handler). See 5.7.

8.2 Invocation

Registered methods enter the Komari RPC registry. They can be called through the existing /api/rpc2 endpoint or by another plugin through server.call(). This document does not repeat the RPC request envelope, error codes, or authentication details; see the RPC documentation.

HTTP example:

bash
curl -s -X POST "http://127.0.0.1:8080/api/rpc2" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "plugin:statusEcho",
    "params": {
      "text": "hello"
    },
    "id": 1
  }'

Calling another plugin's method:

js
const result = await server.call("plugin:statusEcho", {
  text: "hello"
});

8.3 Rules

  • The method name cannot be empty and cannot start with rpc..
  • Registering the same method more than once during a single load of the same plugin is ignored.
  • The handler receives one raw params value and does not automatically unwrap named parameters.
  • The handler is called synchronously. Returning a Promise does not wait for its asynchronous result.
  • The return value must be JSON-serializable. Export failures such as circular references return -32603.
  • The handler times out after permissions.timeout seconds and returns -32011.
  • When the plugin unloads, its methods are unregistered. Later calls return -32601.
  • If a method name conflicts with an existing method, registration fails and plugin loading fails.
  • Plugin methods still go through the unified ACL. Registering a plugin:* method without an additional permission declaration requires the administrator role.

9. Plugin Management HTTP Interfaces

This section documents the HTTP routes exposed by plugin management. Except for chunked uploads and the market, most routes bridge to admin:* RPC methods. Their response shape depends on the target RPC result, which is not repeated here.

All interfaces require administrator authentication. "Standard envelope" means a successful response shaped like { "status": "success", "message": "...", "data": ... }, while errors use { "status": "error", "message": "..." } with an appropriate HTTP status.

9.1 Plugin List

Interface: GET /api/admin/plugin/list

Path parameters: None.

Query parameters: None.

Request body: None.

Return value: Standard envelope whose data is PluginInfo[].

PluginInfo:

FieldTypeDescription
namestring | Record<string,string>Manifest name.
shortstringPlugin short name.
descriptionstring | Record<string,string>Manifest description.
authorstring | Record<string,string>Manifest author.
versionstringVersion.
urlstringPlugin URL.
iconstringRelative icon path.
komaristringServer version constraint.
entrystringEntry script.
permissionsPluginPermissionsPermission object.
configurationConfigurationConfiguration declaration.
pagesPluginPage[]Page declarations. Omitted when absent.
enabledbooleanWhether the plugin is persistently enabled.
runningbooleanWhether the plugin is currently running.
last_errorstringLatest load error, or an empty string.

Example:

bash
curl -s "$BASE/api/admin/plugin/list" \
  -H "Cookie: $COOKIE"

9.2 Enabling or Disabling a Plugin

Interface: POST /api/admin/plugin/enabled

Authentication: Administrator.

Request body:

FieldTypeRequiredDescription
shortstringYesPlugin short name.
enabledbooleanNotrue enables and false disables. Omitted values are treated as false.
approvedbooleanNoWhether to approve the current permission set. It is false when omitted and approval is required.

Return value: Standard success envelope. When permissions are not approved, data is { "requires_approval": true }. After approving, retry with approved: true.

Example:

bash
curl -s -X POST "$BASE/api/admin/plugin/enabled" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "short": "status-extension",
    "enabled": true,
    "approved": true
  }'

9.3 Getting Plugin Logs

Interface: GET /api/admin/plugin/logs?short=<short>

Authentication: Administrator.

Query parameters:

ParameterTypeRequiredDescription
shortstringYesPlugin short name.

Return value: Standard envelope whose data.logs is the log string.

Example:

bash
curl -s "$BASE/api/admin/plugin/logs?short=status-extension" \
  -H "Cookie: $COOKIE"

Example response:

json
{
  "status": "success",
  "data": {
    "logs": "[plugin] loading status-extension\n[plugin] loaded status-extension\n"
  }
}

9.4 Deleting a Plugin

Interface: POST /api/admin/plugin/delete

Authentication: Administrator.

Request body:

FieldTypeRequiredDescription
shortstringYesPlugin short name.

Return value: Standard success envelope. Returns an error when the plugin is not installed.

Example:

bash
curl -s -X POST "$BASE/api/admin/plugin/delete" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{"short":"status-extension"}'

Deletion unloads the runtime and removes data/plugin/<short>, data/plugin-data/<short>, and the persisted state.

9.5 Installing a Plugin with Chunked Upload

Plugin installation uses the shared archive upload interface with purpose set to plugin.

9.5.1 Initialization

Interface: POST /api/admin/upload/init

Request body:

FieldTypeRequiredDescription
purposestringYesMust be "plugin".
filenamestringNoZIP filename. The plugin installation flow does not depend on this field.
sizeintegerYesTotal file size in bytes. It must be greater than 0 and no larger than the backup archive limit.

Return value: Standard envelope. data.upload_id is a UUID and data.chunk_size is 5 MiB.

Example:

bash
curl -s -X POST "$BASE/api/admin/upload/init" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "purpose": "plugin",
    "filename": "status-extension.zip",
    "size": 123456
  }'

9.5.2 Uploading a Chunk

Interface: POST /api/admin/upload/chunk

Content-Type: multipart/form-data

Form fields:

FieldTypeRequiredDescription
upload_idstringYesUUID returned by initialization.
chunk_indexintegerYesZero-based chunk index.
chunk_datafileYesChunk bytes. Every chunk must be exactly 5 MiB except the final chunk.

Return value: Standard envelope. data contains received: true and chunk_index.

Example:

bash
curl -s -X POST "$BASE/api/admin/upload/chunk" \
  -H "Cookie: $COOKIE" \
  -F "upload_id=<upload-id>" \
  -F "chunk_index=0" \
  -F "chunk_data=@chunk-0.bin"

9.5.3 Merging and Installing

Interface: POST /api/admin/upload/merge

Request body:

FieldTypeRequiredDescription
upload_idstringYesUUID returned by initialization.

Return value: Standard envelope. The server currently returns 插件上传成功 as message (Chinese for "plugin uploaded successfully"), and data is the installed Plugin manifest.

Example:

bash
curl -s -X POST "$BASE/api/admin/upload/merge" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{"upload_id":"<upload-id>"}'

9.5.4 Cancelling an Upload

Interface: POST /api/admin/upload/cancel

Request body:

FieldTypeRequiredDescription
upload_idstringYesUUID returned by initialization.

Return value: Standard success envelope.

Example:

bash
curl -s -X POST "$BASE/api/admin/upload/cancel" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{"upload_id":"<upload-id>"}'

9.6 Plugin Market Sources

9.6.1 Listing Sources

Interface: GET /api/admin/plugin/market/sources

Request body: None.

Return value: Standard envelope whose data is PluginMarketSource[].

Example:

bash
curl -s "$BASE/api/admin/plugin/market/sources" \
  -H "Cookie: $COOKIE"

9.6.2 Creating a Source

Interface: POST /api/admin/plugin/market/sources

Request body:

FieldTypeRequiredDescription
namestringYesSource name.
urlstringYesHTTP or HTTPS catalog URL.
enabledbooleanNoWhether the source is enabled. Defaults to false.
idstringNoIgnored. The server generates a new ID.

Return value: Standard success envelope whose data is the created PluginMarketSource.

Example:

bash
curl -s -X POST "$BASE/api/admin/plugin/market/sources" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My catalog",
    "url": "https://example.com/plugins/v1.json",
    "enabled": true
  }'

9.6.3 Updating a Source

Interface: PUT /api/admin/plugin/market/sources/:id

Path parameters:

ParameterTypeDescription
idstringSource ID.

Request body: Same as creating a source. The path parameter overrides id in the request body.

Return value: Standard success envelope whose data is the updated source.

Example:

bash
curl -s -X PUT "$BASE/api/admin/plugin/market/sources/<source-id>" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Updated catalog",
    "url": "https://example.com/plugins/v2.json",
    "enabled": true
  }'

9.6.4 Deleting a Source

Interface: DELETE /api/admin/plugin/market/sources/:id

Path parameters:

ParameterTypeDescription
idstringSource ID.

Return value: Standard success envelope.

Example:

bash
curl -s -X DELETE "$BASE/api/admin/plugin/market/sources/<source-id>" \
  -H "Cookie: $COOKIE"

9.7 Plugin Market Catalog

Interface: GET /api/admin/plugin/market/catalog

Query parameters:

ParameterTypeRequiredDefaultDescription
refreshbooleanNofalseWhen true, bypass the cache.

Return value: Standard envelope. data.plugins is the merged plugin list and data.sources is the status of each source.

PluginMarketPlugin:

FieldTypeDescription
namestring | Record<string,string>Plugin name.
shortstringPlugin short name.
descriptionstring | Record<string,string>Description.
versionstringVersion.
authorstring | Record<string,string>Author.
urlstringPlugin page URL.
downloadstringZIP download URL, or an empty string when no package is available.
sha256stringZIP SHA-256, or an empty string when no package is available.
komaristringServer version constraint.
installablebooleanWhether an installable package exists and the version constraint is satisfied.
source_idstringSource ID.
source_namestringSource name.

sources[]:

FieldTypeDescription
idstringSource ID.
namestringSource name.
urlstringSource URL.
countintegerNumber of plugins read from this source.
errorstringSource read error. Omitted on success.

Example:

bash
curl -s "$BASE/api/admin/plugin/market/catalog?refresh=true" \
  -H "Cookie: $COOKIE"

9.8 Installing a Plugin from the Market

Interface: POST /api/admin/plugin/market/install

Request body:

FieldTypeRequiredDescription
source_idstringYesID of an enabled market source.
shortstringYesShort name of the plugin to install.

Return value: Standard success envelope whose data is the installed manifest.

Example:

bash
curl -s -X POST "$BASE/api/admin/plugin/market/install" \
  -H "Cookie: $COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "source_id": "official",
    "short": "status-extension"
  }'

The server fetches the catalog again, downloads the ZIP, validates its SHA-256, and confirms that the installed short and version match the catalog.

10. Permissions, Limits, and Errors

10.1 Permission Matrix

InterfacePermission
server.route()allowRoutes
server.static()allowRoutes
server.hook()allowHooks
server.injectHTML()allowHTMLInject
server.call()allowSystemRPC
server.registerRPC()Granted by default
server.getConfig()Granted by default
server.cron()Granted by default
File access inside the plugin directoryGranted by default
File access outside the plugin directoryallowAllFileAccess
child_processallowExec and node: true
Listening on a local portallowListen and node: true

10.2 Time and Capacity Limits

ItemCurrent value
Default execution timeout30 seconds
Default HTTP body limit32 MiB
Default child process output limit1 MiB
HTTP hook response buffer limit32 MiB
HTML injection buffer limit32 MiB
WebSocket frame hook limit8 MiB
WebSocket frame hook timeout1 second
Consecutive WebSocket frame drops16 frames

10.3 Error Objects

server.call() rejection:

js
{
  name: "Error",
  message: "method not found",
  code: -32601,
  data: "optional detail"
}

Error thrown by a server.registerRPC() handler:

js
const error = new Error("boom");
error.code = -32045;
error.data = { detail: "x" };
throw error;

Common JSON-RPC error codes:

CodeMeaning
-32700Parse error
-32600Invalid request
-32601Method not found
-32602Invalid params
-32603Internal error
-32011Deadline exceeded
-32040Unauthenticated
-32041Permission denied
-32044Not found
-32045Already exists
-32051Unavailable

10.4 Lifecycle Cleanup

ResourceBehavior when the plugin unloads
Route slot registered by server.route()Retained; requests return 404.
Static route slot registered by server.static()Retained; requests return 404.
HTTP request and response hooksRemoved.
WebSocket hooksRemoved.
Fragments registered by server.injectHTML()Removed.
Jobs registered by server.cron()Cancelled and removed.
Methods registered by server.registerRPC()Unregistered.
Plugin-created timers, listeners, and processesThe runtime closes registered resources. The script should release them explicitly in unload().

Released under the MIT license.