Skip to content

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.

js
const server = require("server");
MethodDescriptionPermission
server.route(method, path, handler)Register an HTTP route on the host engineallowRoutes
server.static(path, dir, opts)Mount a static folder from the plugin directory, optional SPA fallbackallowRoutes
server.hook(kind, matcher?, fn)Register request/response/WebSocket hooksallowHooks
server.injectHTML(head, body)Embed CSS/JS into every HTML pageallowHTMLInject
server.call(method, params...)Call system RPC with admin authorityallowSystemRPC
server.registerRPC(method, handler)Register a plugin-owned RPC methodAlways granted
server.cron(expr, handler)Run handler on a cron scheduleAlways 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:

js
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 to last_error.
  • unload() errors are recorded but do not block unloading.
  • Omitting load() is valid, but it is recommended to put route/hook/RPC registration inside load().
  • During unload() the plugin is already removed from the instance registry, so server.call rejects with "not loaded".

server.route

Registers an HTTP route on the host gin engine:

js
server.route("GET", "/plug", async (req, res) => {
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify({ ok: true }));
});
ArgTypeDescription
methodstringHTTP method, uppercased automatically; must not be empty
pathstringPath; must start with /
handler(req, res) => voidHandler; must call res.end() to finish
  • Requires allowRoutes, otherwise a TypeError is 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

FieldTypeDescription
methodstringRequest method, e.g. "GET"
urlstringRaw request URL (with query string), e.g. "/plug?x=7"
headersobjectRequest headers; keys are lower-cased, single values are strings, multi-values are arrays
queryobjectQuery parameters; multi-values joined with ,
bodystringRequest body (fully read, limited by maxHTTPBodyBytes)
contextobjectRequest context, see below

context carries identity and origin information:

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

MemberTypeDescription
statusCodenumberStatus code, default 200, writable
statusMessagestringStatus text
streamingbooleanSet true to enable streaming: every write() is flushed immediately
isAborted()() => booleanReturns true after the client disconnects (or streaming idle timeout)
setHeader(name, value)fnSet a header (value can be a string or array)
getHeader(name)fnRead a header (string / array / undefined)
removeHeader(name)fnRemove a header
write(data)(Buffer | string) => booleanWrite data; buffered until end() unless streaming
end([data])fnMust 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)

js
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, each res.write() is sent to the client and flushed immediately.
  • When the client disconnects, isAborted() returns true; the script should exit its loop and return (returning ends the stream).
  • Streaming idle timeout beyond timeout also 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:

js
server.static("/ui", "dist");
server.static("/app", "dist", { spa: true }); // SPA mode
ArgTypeDescription
pathstringMount path; must start with / and must not be /; a trailing / is ignored
dirstringRelative folder inside the plugin directory (e.g. "dist"); must exist
optsobjectOptional; { spa: true } enables SPA fallback
  • Requires allowRoutes, otherwise a TypeError is thrown at load time.
  • Serves GET and HEAD: the mount path itself returns index.html from the folder, subpaths return the matching file; a directory resolves to its own index.html.
  • With spa: true, requests that resolve to no file fall back to the folder root index.html (client-side routing refreshes no longer 404); real files always win.
  • Traversal requests (..) are rejected with 404; file resolution stays confined to dir.
  • 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):

js
server.hook("request", (req) => {
  req.headers["x-hooked"] = "yes";
});

server.hook("response", "/api/*", (req, res) => {
  res.statusCode = 201;
  res.body = res.body + "|hooked";
});
ArgTypeDescription
kind"request" | "response" | "wsConnect" | "wsMessage" | "wsSend" | "wsClose"Hook type (case-insensitive)
matcherstring (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)
fnfunctionRequest 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

js
{
  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 maxHTTPBodyBytes declared by the matching hook's plugin (default 32 MiB when undeclared); larger requests return 413.
  • Hooks run in registration order; a hook error → client receives 500 plugin request hook failed and remaining hooks are skipped.
  • Hook execution is bounded by timeout; a timeout is treated as a failure.

Response hook res

js
{
  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-Length so 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.

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

FieldTypeDescription
pathstringEndpoint path, e.g. /api/clients/v2/rpc
connIdnumberUnique connection ID (Go SafeConn.ID)
remoteIpstringTCP source IP
userAgentstringUser-Agent
clientUuidstring | undefinedResolved 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):

FieldTypeDescription
typenumbergorilla frame type: 1=text, 2=binary (control frames are handled internally by the library and never reach hooks)
datastring (type=1) / ArrayBuffer (type=2)Frame payload
connIdnumberSame as ctx
pathstringSame as ctx

Return semantics (synchronous):

ReturnMeaning
undefined / no returnPass 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; drop wins over later hooks.
  • Timeout semantics: frame callbacks run on the plugin event loop but the wait is capped at 1 second (wsConnect/wsClose use the plugin timeout). 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 hookMatcher syntax; 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):

js
server.injectHTML(
  "<style>.plugin-badge{color:red}</style>",
  '<script src="/api/mjpeg_live.js"></script>'
);
ArgTypeDescription
headstringHTML embedded into <head> (style sheets, <style>, <meta>, ...); may be empty
bodystringHTML embedded into <body> (<script>, ...); may be empty
  • Applies to all HTML pages, including the /admin pages, the /terminal pages, the login page, public pages and plugin iframe pages (these are not affected by the site's custom_head/custom_body settings).
  • 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 a TypeError is thrown at load time.

server.call

Invokes any registered system RPC method with admin authority:

js
const result = await server.call("common:getNodes");
const status = await server.call("common:getNodesLatestStatus", { uuid: "..." });
ArgDescription
methodRPC method name, e.g. common:getNodes, admin:getTasks
params...Params. 0 argsnull; 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.message
    • err.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):

js
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;
});
ArgDescription
methodMethod 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.data are 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:

js
server.cron("0 0 9 * * *", async () => {
  // Runs every day at 09:00
});

server.cron("@every 1m", () => {
  // Runs every minute
});
ArgDescription
exprCron 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_error and 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):

js
const config = await server.getConfig();
console.log(config.interval); // defaults already merged

Released under the MIT license.