Skip to content

server 模块与生命周期钩子

插件通过 require("server") 获取与宿主的桥接模块。该模块是插件系统注入的原生 模块,提供路由、钩子、RPC 调用与配置读取能力。

js
const server = require("server");
方法说明权限
server.route(method, path, handler)在宿主引擎注册 HTTP 路由allowRoutes
server.static(path, dir, opts)挂载插件目录内的静态文件夹,可选 SPA 回退allowRoutes
server.hook(kind, matcher?, fn)注册请求/响应/WebSocket 钩子allowHooks
server.injectHTML(head, body)向每个 HTML 页面嵌入 CSS/JSallowHTMLInject
server.call(method, params...)以管理员身份调用系统 RPCallowSystemRPC
server.registerRPC(method, handler)注册插件自己的 RPC 方法始终授予
server.cron(expr, handler)按 cron 表达式定时执行 handler始终授予
server.getConfig()读取配置(合并默认值)始终授予

allowRoutes / allowHooks / allowHTMLInject 缺失时在加载时TypeError (插件加载失败);allowSystemRPC 缺失时 server.call 返回被拒绝的 Promise。

生命周期钩子

入口脚本的顶层代码在加载时立即执行,此外还可选定义两个全局函数:

js
function load() {
  // 每次插件启用/启动时调用(包括服务端启动恢复)
}

function unload() {
  // 禁用、卸载或服务端关闭时调用
}
  • load() 抛错(或顶层脚本抛错)→ 插件自动禁用,错误写入 last_error
  • unload() 的错误仅记录,不阻塞卸载。
  • 不定义 load() 也是合法的,但推荐把注册路由/钩子/RPC 的逻辑放进 load()
  • unload() 期间插件已从实例表移除,此时 server.call 会以「not loaded」拒绝。

server.route

注册一个 HTTP 路由到宿主 gin 引擎:

js
server.route("GET", "/plug", async (req, res) => {
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify({ ok: true }));
});
参数类型说明
methodstringHTTP 方法,自动转大写,不能为空
pathstring路径,必须以 / 开头
handler(req, res) => void处理函数,必须调用 res.end() 结束响应
  • 需要 allowRoutes 权限,否则加载时抛 TypeError
  • 路由槽位在卸载后保留(返回 404),重新加载后恢复;重复注册同一路由(同一次加载) 为无操作。

请求对象 req

字段类型说明
methodstring请求方法,如 "GET"
urlstring原始请求 URL(含查询串),如 "/plug?x=7"
headersobject请求头,键为小写名称,单值时字符串、多值为数组
queryobject查询参数,多值用 , 连接
bodystring请求体(已完整读取,受 maxHTTPBodyBytes 限制)
contextobject请求上下文,见下

context 包含身份与来源信息:

js
{
  principal: {           // 通过身份中间件时才有
    type: "agent" | "user" | "api_key" | "anonymous",
    roles: [...],
    user_uuid,
    client_uuid,
    is_api_key
  },
  role,                  // 用户角色
  user_uuid,             // 用户 UUID
  client_uuid,           // 节点 UUID(Agent 请求)
  remote_ip,             // 客户端 IP
  user_agent             // User-Agent
}

响应对象 res

成员类型说明
statusCodenumber状态码,默认 200,可写
statusMessagestring状态文本
streamingboolean置为 true 开启流式模式:每次 write() 立即发送
isAborted()() => boolean客户端断开(或流式空闲超时)后返回 true
setHeader(name, value)函数设置响应头(value 可为字符串或数组)
getHeader(name)函数读取响应头(单值字符串 / 多值数组 / undefined)
removeHeader(name)函数删除响应头
write(data)(Buffer | string) => boolean写入数据;非流式模式缓冲至 end()
end([data])函数必须调用以结束响应

必须 end()

路由处理器若不调用 res.end()(且非流式模式),客户端会在 timeout 后收到 504 plugin route handler timed out

流式响应(SSE)

js
server.route("GET", "/stream", async (req, res) => {
  res.streaming = true;
  res.setHeader("Content-Type", "text/event-stream");
  while (!res.isAborted()) {
    const data = ...; // 获取一帧数据
    res.write("data: " + data + "\n\n");
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
});
  • 设置 res.streaming = true 后,每次 res.write() 的内容立即 flush 给客户端。
  • 客户端断开连接后 isAborted() 返回 true,脚本应退出循环并返回(处理器返回 即结束)。
  • 流式空闲超过 timeout 也会触发中止并关闭流。

server.static

将插件目录内的一个静态文件夹挂载到指定路径,无需为每个文件编写处理器:

js
server.static("/ui", "dist");
server.static("/app", "dist", { spa: true }); // SPA 模式
参数类型说明
pathstring挂载路径,必须以 / 开头,不能是 /;结尾的 / 会被忽略
dirstring插件目录内的相对文件夹(如 "dist"),必须存在
optsobject可选;{ spa: true } 开启 SPA 回退
  • 需要 allowRoutes 权限,否则加载时抛 TypeError
  • 支持 GETHEAD 请求:path 本身返回目录下的 index.html,子路径返回 对应文件;请求目录时回退到该目录内的 index.html
  • spa: true 时,找不到文件的请求回退到文件夹根的 index.html(前端路由 刷新不再 404);真实存在的文件仍然优先。
  • 路径穿越(..)请求一律 404,文件解析被限定在 dir 内。
  • server.route 相同:挂载槽位在卸载后保留(返回 404),重新加载后恢复; 同一次加载内重复挂载同一路径以最新参数为准。

server.hook

在宿主的 HTTP 链上注册请求钩子响应钩子WebSocket 钩子。默认影响 进入/离开服务端的所有 HTTP 请求(WebSocket 升级请求除外,它们直接穿透 HTTP 钩子, 但会触发 WS 钩子);传入可选的路径过滤后只影响匹配的请求(不匹配的请求完全跳过钩子链, 不做 body 缓冲):

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

server.hook("response", "/api/*", (req, res) => {
  res.statusCode = 201;
  res.body = res.body + "|hooked";
});
参数类型说明
kind"request" | "response" | "wsConnect" | "wsMessage" | "wsSend" | "wsClose"钩子类型(大小写不敏感)
matcherstring(可选)路径过滤:"/api/foo"(精确)、"/api/*"(子树)、"POST /api/foo"(方法 + 路径);大小写不敏感。ws 类只接受路径形式(不含方法前缀,因为升级恒为 GET)
fnfunction请求钩子 fn(req);响应钩子 fn(req, res);ws 钩子见下

需要 allowHooks 权限(请求/响应与 ws 钩子共用),否则加载时抛 TypeError

请求钩子 req

js
{
  method,                 // 可修改:将应用到真实请求
  url,                    // 可修改:将应用到真实请求
  headers,                // 可修改:将替换真实请求头
  query,                  // 查询参数(只读快照)
  body,                   // 可修改:将替换请求体
  context: { remote_ip, user_agent }   // 无身份信息(钩子在身份中间件之前运行)
}
  • 请求体读取上限取匹配钩子所属插件声明的 maxHTTPBodyBytes(未声明时默认 32 MiB);超过上限的请求返回 413
  • 多个钩子按注册顺序依次执行;钩子抛错 → 客户端收到 500 plugin request hook failed, 剩余钩子不再执行。
  • 钩子执行受 timeout 限制,超时同样视为失败。

响应钩子 res

js
{
  statusCode,             // 可修改
  statusMessage,          // 可修改
  headers,                // 可修改:将替换真实响应头
  body                    // 可修改:将替换响应体
}
  • 响应默认被缓冲(上限 32 MiB)以便钩子改写。
  • 改写 body 后原响应携带的 Content-Length 会被移除,由 Go 重新计算长度 (或回退到分块传输),避免客户端截断/挂起。
  • 流式响应(SSE 等,第一次 Flush())或超过 32 MiB 的响应直接穿透, 钩子无法再改写(有日志记录)。
  • 钩子错误仅记录日志,不阻断响应。

WebSocket 钩子

ws 类钩子作用于服务端的全部 WebSocket 端点:Agent 上报 (/api/clients/report/api/clients/v2/rpc)、前端 RPC2(/api/rpc2)、 终端转发(/api/admin/client/:uuid/terminal/api/clients/terminal)与在线列表 (/api/clients)。与请求/响应钩子共用 allowHooks 权限,否则加载时抛 TypeError

js
// 连接级:升级时调用;undefined = 放行,{ deny: true, reason } = 拒绝
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" };
  }
});

// 帧级:每个入站(客户端→服务端)帧
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";         // 改写 Agent 上报的公网 IP
    return { data: JSON.stringify(req) };     // 替换帧
  }
  // return { drop: true };                   // 丢弃该帧(后果自负,见下)
});

// 帧级:每个出站(服务端→客户端)帧
server.hook("wsSend", (ctx, msg) => {
  return { type: msg.type, data: msg.data };  // 原样返回 = 放行
});

// 连接结束通知(wsClose 的 return 被忽略)
server.hook("wsClose", (ctx) => {
  console.log("connection closed", ctx.connId);
});

ctx(连接上下文,连接建立时构造一次,帧回调共享同一对象):

字段类型说明
pathstring端点路径,如 /api/clients/v2/rpc
connIdnumber连接唯一 ID(Go 侧 SafeConn.ID
remoteIpstringTCP 来源 IP
userAgentstringUser-Agent
clientUuidstring | undefined已识别 Agent 的 uuid(v2 握手时已有;v1 需读首帧后才可知)

msg(帧对象,wsMessage/wsSend 相同结构):

字段类型说明
typenumbergorilla 帧类型:1=text、2=binary(控制帧由库内部处理,不会回调)
datastring(type=1)/ ArrayBuffer(type=2)帧载荷
connIdnumber同 ctx
pathstring同 ctx

return 语义(同步返回):

return含义
undefined / 不返回放行,帧原样继续
{ drop: true }丢弃该帧:读侧透明跳过并继续读下一帧;写侧不发送
{ type, data }替换帧(可只给 data,type 不变)

由插件自行承担后果

帧钩子不做协议校验。丢弃帧可能破坏协议:例如丢掉 v2 的 ack 帧会让服务端事件 队列不确认、丢掉终端二进制帧会让终端卡住。平台只保证:被丢弃的帧立即释放、读循环 在连续丢弃 16 帧后结束连接(防止某端点被钩子完全阻塞时死循环)。

  • 多个钩子按注册顺序链式执行:前一个钩子的替换结果作为下一个的输入;drop 优先于后续钩子。
  • 超时语义:帧回调在插件事件循环上执行,但等待上限为 1 秒wsConnect/ wsClose 沿用插件 timeout)。超时/钩子抛错时该帧原样放行并记入插件日志, 绝不让钩子错误拖死读泵(Agent v1 读泵有 11s deadline,v2 更短)。
  • 帧大小上限 8 MiB:超过的帧不经过钩子直接放行(防止大帧复制进事件循环)。
  • 端点级路径过滤复用 hookMatcher 语法;不带 matcher = 全部 WS 端点
  • 卸载插件时 ws 钩子自动移除;已建立的连接在钩子消失后恢复直通。

server.injectHTML

每个 text/html 响应嵌入自定义 CSS/JS:head 片段插入 </head> 之前, body 片段插入 </body> 之前(大小写不敏感;无 </head>head 置于文档开头, 无 </body>body 追加到末尾):

js
server.injectHTML(
  "<style>.plugin-badge{color:red}</style>",
  '<script src="/api/mjpeg_live.js"></script>'
);
参数类型说明
headstring嵌入 <head> 的 HTML(样式表、<style><meta> 等),可为空串
bodystring嵌入 <body> 的 HTML(<script> 等),可为空串
  • 作用于全部 HTML 页面,包括 /admin 管理页、/terminal 终端页、登录页、 公共页与插件 iframe 页(这些页面不受站点设置的 custom_head/custom_body 影响)。
  • 非 HTML 响应不嵌入:JSON、图片、字体、MJPEG/SSE 流等保持原样直通,不缓冲、 不修改。
  • 注入运行在插件响应钩子之后,看到的是钩子改写后的最终 HTML。
  • 单次响应超过 32 MiB、流式响应(第一次 Flush())或 WebSocket 升级请求直接 穿透,不注入。
  • 多个插件按注册顺序依次累加注入;卸载插件后其注入片段自动移除。
  • 需要 allowHTMLInject 权限,否则加载时抛 TypeError

server.call

管理员身份调用任意已注册的系统 RPC 方法:

js
const result = await server.call("common:getNodes");
const status = await server.call("common:getNodesLatestStatus", { uuid: "..." });
参数说明
methodRPC 方法名,如 common:getNodesadmin:getTasks
params...参数。0 个null1 个 → 直接作为参数;多个 → 按位置编组为数组

返回一个 Promise:

  • 成功 → resolve 为 RPC 结果。
  • 失败 → reject 一个 Error,携带 JSON-RPC 错误字段:
    • err.code(整数)
    • err.message
    • err.data(可选)

管理员权限

server.callRoleAdmin 身份调用,等同于管理员在管理后台操作。admin:exec 等敏感操作同样可以直接调用。需要 allowSystemRPC 权限。

全部可调用方法清单见 RPC 接口

server.registerRPC

注册插件自己的 RPC 方法,供前端或其他插件(经 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;
});
参数说明
method方法名,不能为空,不能以 rpc. 开头(保留前缀)
handler(params) => result,返回结果或抛出错误
  • 始终授予,无需权限声明。
  • 同一次加载内重复注册同名方法为无操作;卸载时自动注销。
  • 处理器在插件事件循环上执行;抛出的 JS Error 映射为 JSON-RPC 错误 (err.code / err.message / err.data 会被传递)。
  • 建议使用 plugin:<名称>:<动作> 命名,避免与系统方法冲突。

server.cron

按 cron 表达式在插件事件循环上定时执行 handler:

js
server.cron("0 0 9 * * *", async () => {
  // 每天早上 9 点运行
});

server.cron("@every 1m", () => {
  // 每分钟运行一次
});
参数说明
exprcron 表达式:5 字段(分 时 日 月 周)、6 字段(秒 分 时 日 月 周)或 @every <duration>(如 @every 1m@every 30s);字段支持 **/na-b、逗号列表
handler() => void,每次触发时在插件事件循环上执行
  • 始终授予,无需权限声明。
  • 非法表达式使加载失败(错误写入 last_error,插件自动禁用)。
  • 同一加载内可注册多个任务;卸载或加载失败时全部自动移除。
  • handler 抛出的错误记录到插件日志,不影响后续触发。

server.getConfig

读取插件的保存配置(与清单声明的默认值合并):

js
const config = await server.getConfig();
console.log(config.interval); // 已合并默认值

Released under the MIT license.