Skip to content

definePlugin

声明一个插件不需要继承基类,也不用往任何注册表里手工挂条目——默认导出 definePlugin(...)(来自 @zhin.js/plugin-runtime)的返回值就够了。这份返回值叫 PluginDefinition:名字、元数据、依赖声明,外加一个 setup(context) 装配函数。

ts
import { definePlugin } from '@zhin.js/plugin-runtime';

export default definePlugin<MyConfig>({
  name: 'my-plugin',
  metadata: { displayName: 'My Plugin', icon: 'Blocks', order: 10 },
  requires: [databaseHostToken],
  async setup(context) {
    // 装配:注册命令之外的运行时资源(表、cron、Agent 工具、出站推送……)
    return () => { /* generation 结束时执行 */ };
  },
});

几个硬约束先记住。name 必填,必须匹配 ^[a-z][a-z0-9-]*$,否则 definePlugin 直接抛 TypeError;返回的 definition 被 Object.freeze,不可再改。setup 可同步、可 async,可选地返回一个 Dispose,在当前代(generation)结束时执行。

从零起步:最短看 single-file-bot;约定目录教程见 编写第一个插件;概念见 插件模型

setup context

setup 收到的 PluginSetupContext<TConfig> 成员全部只读:

成员类型作用
pluginPluginInstanceView实例视图:id / instanceKey / parent / root / role'root' | 'child')。多实例部署时按 instanceKey 隔离
configConfigView<TConfig>配置视图,config.get() 返回只读配置。默认值来自插件包 schema.json,由 zhin.config.ymlplugin: 段(Root 自身)或 plugins.<key> 段覆盖
resourcesScope资源作用域:has(token) / use(token) 解析 Host token,provide(token, value, dispose?) 向子作用域发布资源
lifecycleDisposeStack代的回收栈:lifecycle.add(dispose) 登记的清理函数在代结束时按逆序执行
handoffGenerationHandoffRegistry代际交接注册表:handoff.add(participant) 参与热重载事务(见下文「代际交接」)
addFeature(feature, name, definition)通用 Feature 注册入口将内存 definition 注册为当前插件的 Capability;仍走 provider 校验、冲突检测和 generation transaction

导入 Stable Feature 的 authoring 包后,context 会通过类型扩展获得对应快捷方法: addCommandaddComponentaddMiddlewareaddAdapter。可选 AI 能力同样提供 addAgentaddSkilladdTooladdMcp。它们只是 addFeature 的强类型窄化, 不会建立第二套注册表。

ts
import { defineCommand } from '@zhin.js/command';
import { defineComponent } from '@zhin.js/component';
import { definePlugin } from '@zhin.js/plugin-runtime';

export default definePlugin({
  name: 'single-file-bot',
  setup({ addCommand, addComponent }) {
    addCommand('hello', defineCommand({
      description: 'Say hello',
      execute: () => 'Hello.',
    }));
    addComponent('status', defineComponent({
      render: () => 'online',
    }));
  },
});

快捷注册与 commands/hello.tscomponents/status.tsx 的目录发现最终生成相同的 CapabilitySlot。两者同名会在 prepare 阶段报 Duplicate Capability Slot;未挂载对应 Feature 时也会拒绝启动。单文件入口修改会重建该插件 Scope,拆成约定目录后则可获得 单能力文件级 HMR。

自定义 Feature 可在 provider 的 authoring.setupMethod 声明自己的快捷方法名(必须是 addXxx 形式),Runtime 会动态安装它;TypeScript 类型由该 Feature 对 PluginSetupContext 做 module augmentation。Runtime 不维护 Feature 名称白名单。

看一段真实装配就清楚了(examples/capabilities-bot/plugin.ts,节选):

ts
async setup(context) {
  const { instanceKey } = context.plugin;            // ① 实例视图
  const config = context.config.get();               // ② 配置视图
  if (context.resources.has(databaseHostToken)) {    // ③ 资源作用域
    const db = context.resources.use(databaseHostToken);
    db.define('showcase_counter', { /* … */ });
  }
  context.lifecycle.add(schedule.register({ /* … */ })); // ④ 生命周期回收
  return () => console.log('disposed');              // ⑤ setup 返回 Dispose
}

Scope 解析规则

resources 是一条父子链:use(token) 先查本作用域,未命中则向父作用域递归;整条链都没有则抛 Missing resource 错误。可选能力一律先 has(token)use(token),缺失时自行降级。

metadata 与 requires

metadata 的三个字段全部服务于 Remote Console 的插件卡片(/api/plugins):displayName 是展示名,icon 是图标名,order 是排序权重。都不影响运行时行为。

requires 声明硬依赖的 Host token 数组,缺失即拒绝启动——与 has() + 降级的软依赖路径互为补充:

ts
// 硬依赖:没有数据库就不启动
export default definePlugin({
  name: 'my-plugin',
  requires: [databaseHostToken],
  // …
});

Host token

Host token 是 Host 提供给插件的能力句柄,setup 里通过 context.resources 解析。CLI Host 启动时自动装配,未装配的 token 用 has() 判空降级。前六个从 @zhin.js/plugin-runtime 导出,httpHostToken@zhin.js/host-http 导出。

tokentoken id提供条件关键方法
databaseHostTokenzhin.database.host配置了 database:define(name, columns) 注册表;models.get(name) 取模型(select / insert / update / delete / count);start() 由 Host 在代激活时调用,插件不自己调
scheduleHostTokenzhin.schedule.host始终可用register(job) 注册 6 段 solar cron(秒 分 时 日 月 周),返回取消函数;list() 列出任务
outboundHostTokenzhin.outbound.host有可用 Adaptersend(input) 主动推送(返回平台消息 id 或 null);可选 addReaction / removeReaction / recall
agentToolsHostTokenzhin.agent-tools.host安装并启用 AI(Agent Host)register(tool) 注册 Agent 工具,返回注销函数
htmlRendererTokenzhin.html-renderer.host安装了 @zhin.js/html-rendererrender(html, { width, format, backgroundColor }) → PNG(Buffer)或 SVG(string);未安装时必须降级为纯文本
runtimeEventPublisherTokenzhin.runtime.event-publisherRoot 级,CLI console 装配publish(type, data) 向 Console SSE hub 广播事件(适配器用来推 endpoint:request / endpoint:notice 等)
httpHostTokenzhin.host.httpHTTP Host 启用route(method, path, handler, meta?) 注册 HTTP 路由;ws(path).onConnection(cb) 注册 WS 端点;listen() / close() 由 Host 管理

注意这两段典型用法的共同点:先 has() 守卫,再把返回的注销函数挂进 lifecycle——热重载时自动回收。

ts
// 定时任务:dispose 挂 lifecycle,热重载安全回收
if (config.heartbeatCron && context.resources.has(scheduleHostToken)) {
  const schedule = context.resources.use(scheduleHostToken);
  context.lifecycle.add(schedule.register({
    id: 'capabilities-bot/heartbeat',
    cron: config.heartbeatCron,
    description: 'Showcase heartbeat',
    execute: () => log('heartbeat ♥'),
  }));
}

// Agent 工具:装了 Agent Host 才存在,未装静默跳过
if (context.resources.has(agentToolsHostToken)) {
  const agentTools = context.resources.use(agentToolsHostToken);
  context.lifecycle.add(agentTools.register({
    name: 'showcase_greet',
    description: 'Return the configured greeting for a name',
    source: 'capabilities-bot',
    inputSchema: {
      type: 'object',
      properties: { name: { type: 'string' } },
      required: ['name'],
    },
    execute: (input) => `${config.greeting},${String(input.name ?? 'world')}!`,
  }));
}

AgentToolRegistration 的常用字段:name(模型可见的工具名)、descriptioninputSchema(zod 或 JSON Schema)、platforms / scopes / permissions(按消息维度限制可见性)、hidden(只按名调用、不进入工具目录)、approval'never' | 'always',与执行策略叠加)、source(来源标注)。

代际交接(handoff)

热重载是一次「代」事务:新一代装配完成后,旧代静默、新代激活、失败则回滚。context.handoff.add(participant) 注册参与者,可实现的钩子:

钩子时机
quiescePrevious(previous)旧代静默(如暂停接收新事件)
activateNext()新代激活(如启动 endpoint、启动 cron 后的首个动作)
deactivateNext()激活失败时回滚新代
resumePrevious()回滚后恢复旧代
openNext()事务提交后开放准入(如开始收消息)

capabilities-bot 用它解决启动时序竞争——endpoint 就绪后再推上线消息:

ts
context.handoff.add({
  activateNext: async () => {
    await outbound.send({ ...target, content: `${config.greeting},capabilities-bot 已上线` });
  },
});

真实示例

  • capabilities-botplugin.ts 一个 setup() 调动全部常用 Host 面(database / schedule / agent-tools / outbound / handoff),每一项都配 has() 降级,是本文所有代码片段的来源。
  • lotteryplugins/utils/lottery/plugin.ts):生产级插件——数据库优先用 databaseHostToken、缺省落内存实现;provide 自有 token 给命令复用;Agent 工具走 await import() 惰性加载,保证 IM-only 安装不引入 @zhin.js/agent;cron 每日流水线。

lottery 的装配骨架值得抄:

ts
// plugins/utils/lottery/plugin.ts(节选)
async setup(context) {
  const config = resolveLotteryConfig(context.config.get());
  const db = context.resources.has(databaseHostToken)
    ? context.resources.use(databaseHostToken)
    : createInMemoryLotteryDb();
  if (context.resources.has(databaseHostToken)) {
    defineLotteryTables(db);
  }
  context.resources.provide(lotteryRuntimeToken, { db });

  // Agent 工具惰性加载:IM-only 安装不含 @zhin.js/agent
  if (context.resources.has(agentToolsHostToken)) {
    const agentTools = context.resources.use(agentToolsHostToken);
    const { registerLotteryAgentTools } = await import('./agent/runtime-tools.js');
    context.lifecycle.add(registerLotteryAgentTools(agentTools));
  }
  // …cron 注册见 scheduleHostToken 一节
}

package.json zhin 字段

插件包(以及 feature 包)用 package.json 顶层的 zhin 字段声明清单,取代旧的 plugin.yml。解析与强校验在 @zhin.js/runtimepackages/im/runtime/src/manifest.ts),非法清单直接抛 ManifestValidationError

jsonc
{
  "zhin": {
    "protocol": 1,                    // 必填,目前恒为 1
    "type": "plugin",                 // 必填:"plugin" | "feature"
    "entry": "./plugin.js",           // 必填:发布包使用 JS;本地私有 Root 可直接使用 ./plugin.ts
    "engine": "^1.0.0",               // 可选:对 Runtime engine 版本的 semver 要求,不满足拒绝加载
    "runtime": "trusted",             // 可选(仅 plugin):"trusted"(默认)| "isolated"
    "platformFeatures": true,         // 可选(仅 plugin):默认 true,Root 插件自动获得官方 Stable Features;设 false 退出
    "features": [                     // 可选(仅 plugin):依赖的 Feature 包清单,缺省 []
      { "package": "@zhin.js/command", "api": "^1.0.0", "optional": false }
    ],
    "plugins": [                      // 可选(仅 plugin):挂载的子插件实例清单,缺省 []
      { "package": "@zhin.js/adapter-icqq", "instanceKey": "icqq" }
    ]
  }
}

字段细则:

字段必填含义
protocol清单协议版本,当前必须为 1
typeplugin(可挂载子插件、可声明 features/runtime)或 feature(平台能力提供者)
entry插件/功能入口文件,包相对路径,./ 开头且不得含 ..
enginesemver range,对 Runtime 提供的 engine 版本做 satisfies 校验
runtime否(仅 plugin)isolated 表示在隔离运行时中执行:仅限子插件(Root 不可用)、不得挂载 Host Feature,且需要 isolation adapter 支持
platformFeatures否(仅 plugin)默认 true:Root 插件即使不声明也会获得官方 Stable Features(@zhin.js/adaptercommandcomponent
features否(仅 plugin)依赖的 Feature 包:package 支持 npm 包名或 ./ 相对路径(monorepo 本地);api 是对该 Feature 声明的 featureApi 版本的 semver 要求;optional 标记缺失可降级
plugins否(仅 plugin)挂载子插件实例:packagefeatures 的包名规则;instanceKey 必填,匹配 ^[a-z0-9][a-z0-9-]*$,是实例隔离与配置的键——同一包挂多个实例就靠不同 instanceKey,实例配置写在 app 的 zhin.config.ymlplugins.<instanceKey>

feature 包(type: "feature")的清单只有 protocol / type / entry / engine / featureApi 五个字段,其中 featureApi(可选)声明本 feature 实现的 API 版本,供消费方 features[].api 校验。

开发源码与 npm 发布入口

本地 Root Plugin 和 workspace 私有插件可以把 zhin.entry 写成 ./plugin.ts,Node 原生 TypeScript 与 HMR 会直接加载源码。发布到 npm 的插件必须把入口声明为 ./plugin.js,并在 files 中包含 plugin.js、约定目录下生成的 JavaScript 与 lib

jsonc
{
  "files": ["lib", "plugin.js", "commands", "middlewares"],
  "scripts": {
    "build": "tsc",
    "prepack": "pnpm run build && node ../../../scripts/build-plugin-runtime-entries.mjs",
    "postpack": "node ../../../scripts/build-plugin-runtime-entries.mjs --clean"
  },
  "zhin": {
    "protocol": 1,
    "type": "plugin",
    "entry": "./plugin.js"
  }
}

仓库内官方插件统一在 prepack 使用 build-plugin-runtime-entries.mjs:它将 plugin.ts 和约定目录中的 TypeScript 编译成同目录 JavaScript,并把指向 src/ 的相对导入改写到 lib/;tarball 创建后,postpack --clean 清除这些带生成标记的同目录产物。普通 build 不生成它们,因此 workspace 测试和 HMR 始终命中 TypeScript 源码;从 node_modules 加载时则优先选择发布包内的 JavaScript,避免 Node 拒绝对依赖包执行类型剥离。

下一步