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) 注册插件私有逻辑表;Runtime 按 PluginId 映射物理表名,models.get(name) 只访问本插件表(select / insert / update / delete / count
scheduleHostTokenzhin.schedule.host始终可用register(job) 注册插件私有逻辑任务 ID 的 6 段 solar cron(秒 分 时 日 月 周),返回取消函数;list() 只列出本插件任务

databaseHostTokenscheduleHostToken 不暴露进程级 start / stop、Console 管理端口或原始数据库。CLI 负责这些 root-only 生命周期;插件只使用逻辑表名和任务 ID,因此同名资源不会与 sibling/child 插件冲突。 | outboundHostToken | zhin.outbound.host | 有可用 Adapter | send(input) 主动推送(返回平台消息 id 或 null);可选 addReaction / removeReaction / recall | | htmlRendererToken | zhin.html-renderer.host | 安装了 @zhin.js/html-renderer | render(html, { width, format, backgroundColor }) → PNG(Buffer)或 SVG(string);未安装时必须降级为纯文本 | | runtimeEventPublisherToken | zhin.runtime.event-publisher | Root 级,CLI console 装配 | publish(type, data) 向 Console SSE hub 广播事件(适配器用来推 endpoint:request / endpoint:notice 等) | | httpHostToken | zhin.host.http | HTTP Host 启用 | route(method, path, handler, meta?) 注册 HTTP 路由;ws(path).onConnection(cb) 注册 WS 端点;listen() / close() 由 Host 管理 |

Host token 注册返回的注销函数要挂进 lifecycle;Tool capability 则直接写候选 generation,无需手工清理。

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 工具:与 tools/*.ts 共用同一个候选 capability table
context.addTool('showcase_greet', defineAgentTool<{ name?: string }>({
    description: 'Return the configured greeting for a name',
    approval: 'never',
    inputSchema: {
      type: 'object',
      properties: { name: { type: 'string' } },
      required: ['name'],
    },
    execute: (input) => `${config.greeting},${String(input.name ?? 'world')}!`,
}));

代际交接(handoff)

热重载是一次「代」事务:候选代完成所有可失败的 readiness 后,快照与准入 gate 一次原子发布;旧代在发布前始终继续服务。context.handoff.add(participant) 注册候选资源参与者:

钩子时机
activateNext(signal)候选资源建立连接并证明 ready;必须支持取消
deactivateNext()激活失败时回滚新代

例如,自定义资源必须连接成功才能允许新一代发布:

ts
context.handoff.add({
  activateNext: (signal) => client.connect({ signal }),
  deactivateNext: () => client.close(),
});

真实示例

  • 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 });

  context.addTool('lottery_sync', createLotterySyncTool());
  // …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 拒绝对依赖包执行类型剥离。

下一步