server Module & Lifecycle Hooks
Plugins get their bridge to the host via require("server"). This native module is injected by the plugin system and provides routing, hooks, RPC invocation, and configuration access.
const server = require("server");| Method | Description | Permission |
|---|---|---|
server.route(method, path, handler) | Register an HTTP route on the host engine | allowRoutes |
server.static(path, dir, opts) | Mount a static folder from the plugin directory, optional SPA fallback | allowRoutes |
server.hook(kind, matcher?, fn) | Register request/response/WebSocket hooks | allowHooks |
server.injectHTML(head, body) | Embed CSS/JS into every HTML page | allowHTMLInject |
server.call(method, params...) | Call system RPC with admin authority | allowSystemRPC |
server.registerRPC(method, handler) | Register a plugin-owned RPC method | Always granted |
server.cron(expr, handler) | Run handler on a cron schedule | Always granted |
server.getConfig() | Read configuration (merged with defaults) | Always granted |
Missing allowRoutes / allowHooks / allowHTMLInject throws TypeError at load time (plugin load fails); missing allowSystemRPC rejects the Promise returned by server.call.
Lifecycle Hooks
The entry script's top-level code runs immediately at load. Additionally, two optional global functions may be defined:
function load() {
// Called every time the plugin is enabled/started (including startup recovery)
}
function unload() {
// Called on disable, uninstall, or server shutdown
}- A
load()error (or a top-level script error) → the plugin is auto-disabled and the error is written tolast_error. unload()errors are recorded but do not block unloading.- Omitting
load()is valid, but it is recommended to put route/hook/RPC registration insideload(). - During
unload()the plugin is already removed from the instance registry, soserver.callrejects with "not loaded".
server.route
Registers an HTTP route on the host gin engine:
server.route("GET", "/plug", async (req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true }));
});| Arg | Type | Description |
|---|---|---|
method | string | HTTP method, uppercased automatically; must not be empty |
path | string | Path; must start with / |
handler | (req, res) => void | Handler; must call res.end() to finish |
- Requires
allowRoutes, otherwise aTypeErroris thrown at load time. - Route slots survive unload (they return 404) and are restored on reload; re-registering the same route within one load is a no-op.
Request object req
| Field | Type | Description |
|---|---|---|
method | string | Request method, e.g. "GET" |
url | string | Raw request URL (with query string), e.g. "/plug?x=7" |
headers | object | Request headers; keys are lower-cased, single values are strings, multi-values are arrays |
query | object | Query parameters; multi-values joined with , |
body | string | Request body (fully read, limited by maxHTTPBodyBytes) |
context | object | Request context, see below |
context carries identity and origin information:
{
principal: { // only when the identity middleware ran
type: "agent" | "user" | "api_key" | "anonymous",
roles: [...],
user_uuid,
client_uuid,
is_api_key
},
role, // user role
user_uuid, // user UUID
client_uuid, // client UUID (agent requests)
remote_ip, // client IP
user_agent // User-Agent
}Response object res
| Member | Type | Description |
|---|---|---|
statusCode | number | Status code, default 200, writable |
statusMessage | string | Status text |
streaming | boolean | Set true to enable streaming: every write() is flushed immediately |
isAborted() | () => boolean | Returns true after the client disconnects (or streaming idle timeout) |
setHeader(name, value) | fn | Set a header (value can be a string or array) |
getHeader(name) | fn | Read a header (string / array / undefined) |
removeHeader(name) | fn | Remove a header |
write(data) | (Buffer | string) => boolean | Write data; buffered until end() unless streaming |
end([data]) | fn | Must be called to finish the response |
Must call end()
If the handler never calls res.end() (and is not streaming), the client receives 504 plugin route handler timed out after timeout.
Streaming responses (SSE)
server.route("GET", "/stream", async (req, res) => {
res.streaming = true;
res.setHeader("Content-Type", "text/event-stream");
while (!res.isAborted()) {
const data = ...; // fetch one frame
res.write("data: " + data + "\n\n");
await new Promise((resolve) => setTimeout(resolve, 50));
}
});- With
res.streaming = true, eachres.write()is sent to the client and flushed immediately. - When the client disconnects,
isAborted()returnstrue; the script should exit its loop and return (returning ends the stream). - Streaming idle timeout beyond
timeoutalso aborts and closes the stream.
server.static
Mounts a static folder from the plugin directory at a given path, without writing a handler per file:
server.static("/ui", "dist");
server.static("/app", "dist", { spa: true }); // SPA mode| Arg | Type | Description |
|---|---|---|
path | string | Mount path; must start with / and must not be /; a trailing / is ignored |
dir | string | Relative folder inside the plugin directory (e.g. "dist"); must exist |
opts | object | Optional; { spa: true } enables SPA fallback |
- Requires
allowRoutes, otherwise aTypeErroris thrown at load time. - Serves
GETandHEAD: the mount path itself returnsindex.htmlfrom the folder, subpaths return the matching file; a directory resolves to its ownindex.html. - With
spa: true, requests that resolve to no file fall back to the folder rootindex.html(client-side routing refreshes no longer 404); real files always win. - Traversal requests (
..) are rejected with 404; file resolution stays confined todir. - Like
server.route: mount slots survive unload (they return 404) and are restored on reload; re-mounting the same path within one load refreshes the config.
server.hook
Registers request, response or WebSocket hooks on the host HTTP chain. By default they affect all HTTP requests entering/leaving the server (WebSocket upgrade requests pass through the HTTP hooks untouched, but trigger the ws hooks); with an optional path filter they only run for matching requests (non-matching requests skip the hook chain entirely, including body buffering):
server.hook("request", (req) => {
req.headers["x-hooked"] = "yes";
});
server.hook("response", "/api/*", (req, res) => {
res.statusCode = 201;
res.body = res.body + "|hooked";
});| Arg | Type | Description |
|---|---|---|
kind | "request" | "response" | "wsConnect" | "wsMessage" | "wsSend" | "wsClose" | Hook type (case-insensitive) |
matcher | string (optional) | Path filter: "/api/foo" (exact), "/api/*" (subtree), "POST /api/foo" (method + path); case-insensitive. The ws kinds accept path-only matchers (no method prefix, since every upgrade is a GET) |
fn | function | Request hook fn(req); response hook fn(req, res); ws hooks see below |
Requires allowHooks (shared by the request/response and ws kinds), otherwise a TypeError is thrown at load time.
Request hook req
{
method, // mutable: applied to the real request
url, // mutable: applied to the real request
headers, // mutable: replaces the real request headers
query, // query params (read-only snapshot)
body, // mutable: replaces the request body
context: { remote_ip, user_agent } // no identity (hooks run before the identity middleware)
}- Request bodies are read up to the
maxHTTPBodyBytesdeclared by the matching hook's plugin (default 32 MiB when undeclared); larger requests return413. - Hooks run in registration order; a hook error → client receives
500 plugin request hook failedand remaining hooks are skipped. - Hook execution is bounded by
timeout; a timeout is treated as a failure.
Response hook res
{
statusCode, // mutable
statusMessage, // mutable
headers, // mutable: replaces the real response headers
body // mutable: replaces the response body
}- Responses are buffered (up to 32 MiB) so hooks can rewrite them.
- Rewriting the body drops the original response's
Content-Lengthso Go recomputes the length (or falls back to chunked encoding) instead of truncating or hanging the client. - Streaming responses (SSE, after the first
Flush()) or responses larger than 32 MiB pass through untouched; hooks can no longer rewrite them (logged). - Hook errors are logged only and do not block the response.
WebSocket hooks
The ws kinds target every WebSocket endpoint on the server: agent reporting (/api/clients/report, /api/clients/v2/rpc), the web RPC2 channel (/api/rpc2), terminal forwarding (/api/admin/client/:uuid/terminal, /api/clients/terminal) and the online list (/api/clients). They share the allowHooks permission with the request/response kinds; otherwise a TypeError is thrown at load time.
// Connection-level: runs at upgrade time; undefined = allow, { deny, reason } = reject
server.hook("wsConnect", (ctx) => {
if (ctx.path === "/api/clients/v2/rpc" && ctx.remoteIp.startsWith("10.")) {
return { deny: true, reason: "intranet agents must use the private endpoint" };
}
});
// Frame-level: every inbound (client → server) frame
server.hook("wsMessage", "/api/clients/v2/rpc", (ctx, msg) => {
if (msg.type !== 1) return; // 1 = text, 2 = binary
let req = JSON.parse(msg.data);
if (req.method === "agent.basicInfo") {
req.params.info.ipv4 = "1.2.3.4"; // rewrite the agent-reported public IP
return { data: JSON.stringify(req) }; // replace the frame
}
// return { drop: true }; // drop the frame (your responsibility, below)
});
// Frame-level: every outbound (server → client) frame
server.hook("wsSend", (ctx, msg) => {
return { type: msg.type, data: msg.data }; // returning the same values = pass through
});
// Connection teardown notification (the return value is ignored)
server.hook("wsClose", (ctx) => {
console.log("connection closed", ctx.connId);
});ctx (connection context, built once at connect; frame callbacks share it):
| Field | Type | Description |
|---|---|---|
path | string | Endpoint path, e.g. /api/clients/v2/rpc |
connId | number | Unique connection ID (Go SafeConn.ID) |
remoteIp | string | TCP source IP |
userAgent | string | User-Agent |
clientUuid | string | undefined | Resolved agent uuid (available at upgrade for v2; v1 needs the first frame, so it may be undefined) |
msg (frame object, same shape for wsMessage and wsSend):
| Field | Type | Description |
|---|---|---|
type | number | gorilla frame type: 1=text, 2=binary (control frames are handled internally by the library and never reach hooks) |
data | string (type=1) / ArrayBuffer (type=2) | Frame payload |
connId | number | Same as ctx |
path | string | Same as ctx |
Return semantics (synchronous):
| Return | Meaning |
|---|---|
undefined / no return | Pass through, frame continues unchanged |
{ drop: true } | Drop the frame: the read side transparently skips it and keeps reading; the write side does not send it |
{ type, data } | Replace the frame (only data is enough; type stays) |
The plugin bears the consequences
Frame hooks do no protocol validation. Dropping frames can break the protocol: dropping a v2 ack frame leaves the server event queue unacknowledged, dropping a terminal binary frame stalls the terminal. The platform only guarantees: dropped frames are released immediately, and the read loop ends the connection after 16 consecutive drops (so a fully filtered endpoint cannot spin forever).
- Multiple hooks run in registration order as a chain: each hook sees the previous hook's replacement;
dropwins over later hooks. - Timeout semantics: frame callbacks run on the plugin event loop but the wait is capped at 1 second (
wsConnect/wsCloseuse the plugintimeout). On timeout or a hook error the frame passes through unchanged and the plugin log records it, so a hook can never stall the read pumps (the v1 agent pump runs on an 11s deadline, v2 shorter). - Frame size cap of 8 MiB: larger frames bypass the hooks entirely.
- Path filtering reuses the
hookMatchersyntax; no matcher = every WS endpoint. - Unloading a plugin removes its ws hooks; established connections revert to pass-through.
server.injectHTML
Embeds custom CSS/JS into every text/html response: the head fragment is inserted before </head>, the body fragment before </body> (case-insensitive; if </head> is absent the head fragment is prepended, if </body> is absent the body fragment is appended):
server.injectHTML(
"<style>.plugin-badge{color:red}</style>",
'<script src="/api/mjpeg_live.js"></script>'
);| Arg | Type | Description |
|---|---|---|
head | string | HTML embedded into <head> (style sheets, <style>, <meta>, ...); may be empty |
body | string | HTML embedded into <body> (<script>, ...); may be empty |
- Applies to all HTML pages, including the
/adminpages, the/terminalpages, the login page, public pages and plugin iframe pages (these are not affected by the site'scustom_head/custom_bodysettings). - Non-HTML responses are never modified: JSON, images, fonts, MJPEG/SSE streams, etc. pass through unmodified and are not buffered.
- Injection runs after the plugin response hooks, so it sees the final rewritten HTML.
- Responses larger than 32 MiB, streaming responses (after the first
Flush()) and WebSocket upgrade requests pass through without injection. - Multiple plugins accumulate in registration order; unloading a plugin removes its fragments automatically.
- Requires
allowHTMLInject, otherwise aTypeErroris thrown at load time.
server.call
Invokes any registered system RPC method with admin authority:
const result = await server.call("common:getNodes");
const status = await server.call("common:getNodesLatestStatus", { uuid: "..." });| Arg | Description |
|---|---|
method | RPC method name, e.g. common:getNodes, admin:getTasks |
params... | Params. 0 args → null; 1 arg → passed as-is; N args → marshalled into a positional array |
Returns a Promise:
- Success → resolves to the RPC result.
- Failure → rejects an Error carrying JSON-RPC error fields:
err.code(integer)err.messageerr.data(optional)
Admin authority
server.call runs with RoleAdmin, equivalent to an admin operating the panel — including sensitive operations such as admin:exec. Requires allowSystemRPC.
See RPC Methods for the full method inventory.
server.registerRPC
Registers a plugin-owned RPC method, callable by the frontend or by other plugins (via server.call):
server.registerRPC("plugin:greet", (params) => {
return { echo: params, from: "example" };
});
server.registerRPC("plugin:fail", () => {
const err = new Error("boom");
err.code = -32045;
err.data = { detail: "x" };
throw err;
});| Arg | Description |
|---|---|
method | Method name; must not be empty and must not start with rpc. (reserved prefix) |
handler | (params) => result; return a result or throw an error |
- Always granted, no permission declaration needed.
- Re-registering the same method within one load is a no-op; methods are unregistered on unload.
- Handlers run on the plugin event loop; thrown JS
Errors map to JSON-RPC errors (err.code/err.message/err.dataare propagated). - Prefer the
plugin:<name>:<action>naming convention to avoid clashes with system methods.
server.cron
Runs a handler on the plugin event loop on a cron schedule:
server.cron("0 0 9 * * *", async () => {
// Runs every day at 09:00
});
server.cron("@every 1m", () => {
// Runs every minute
});| Arg | Description |
|---|---|
expr | Cron expression: 5 fields (minute hour day-of-month month day-of-week), 6 fields (second minute hour day-of-month month day-of-week), or @every <duration> (e.g. @every 1m, @every 30s); fields support *, */n, a-b, and comma lists |
handler | () => void; runs on the plugin event loop on every fire |
- Always granted, no permission declaration needed.
- An invalid expression fails the load (the error is written to
last_errorand the plugin is auto-disabled). - Multiple jobs may be registered per load; all are removed automatically on unload or load failure.
- Errors thrown by the handler are logged to the plugin log and do not stop future fires.
server.getConfig
Reads the plugin's saved configuration (merged with manifest defaults):
const config = await server.getConfig();
console.log(config.interval); // defaults already merged- Returns a Promise resolving to a
{ [key]: value }object. - Merge rules: see Manifest Reference - Default value merge rules.
- Always granted.