definePlugin
声明一个插件不需要继承基类,也不用往任何注册表里手工挂条目——默认导出 definePlugin(...)(来自 @zhin.js/plugin-runtime)的返回值就够了。这份返回值叫 PluginDefinition:名字、元数据、依赖声明,外加一个 setup(context) 装配函数。
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> 成员全部只读:
| 成员 | 类型 | 作用 |
|---|---|---|
plugin | PluginInstanceView | 实例视图:id / instanceKey / parent / root / role('root' | 'child')。多实例部署时按 instanceKey 隔离 |
config | ConfigView<TConfig> | 配置视图,config.get() 返回只读配置。默认值来自插件包 schema.json,由 zhin.config.yml 的 plugin: 段(Root 自身)或 plugins.<key> 段覆盖 |
resources | Scope | 资源作用域:has(token) / use(token) 解析 Host token,provide(token, value, dispose?) 向子作用域发布资源 |
lifecycle | DisposeStack | 代的回收栈:lifecycle.add(dispose) 登记的清理函数在代结束时按逆序执行 |
handoff | GenerationHandoffRegistry | 代际交接注册表:handoff.add(participant) 参与热重载事务(见下文「代际交接」) |
addFeature(feature, name, definition) | 通用 Feature 注册入口 | 将内存 definition 注册为当前插件的 Capability;仍走 provider 校验、冲突检测和 generation transaction |
导入 Stable Feature 的 authoring 包后,context 会通过类型扩展获得对应快捷方法: addCommand、addComponent、addMiddleware、addAdapter。可选 AI 能力同样提供 addAgent、addSkill、addTool、addMcp。它们只是 addFeature 的强类型窄化, 不会建立第二套注册表。
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.ts、components/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,节选):
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() + 降级的软依赖路径互为补充:
// 硬依赖:没有数据库就不启动
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 导出。
| token | token id | 提供条件 | 关键方法 |
|---|---|---|---|
databaseHostToken | zhin.database.host | 配置了 database: | define(name, columns) 注册插件私有逻辑表;Runtime 按 PluginId 映射物理表名,models.get(name) 只访问本插件表(select / insert / update / delete / count) |
scheduleHostToken | zhin.schedule.host | 始终可用 | register(job) 注册插件私有逻辑任务 ID 的 6 段 solar cron(秒 分 时 日 月 周),返回取消函数;list() 只列出本插件任务 |
databaseHostToken 与 scheduleHostToken 不暴露进程级 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,无需手工清理。
// 定时任务: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() | 激活失败时回滚新代 |
例如,自定义资源必须连接成功才能允许新一代发布:
context.handoff.add({
activateNext: (signal) => client.connect({ signal }),
deactivateNext: () => client.close(),
});真实示例
- capabilities-bot:
plugin.ts一个setup()调动全部常用 Host 面(database / schedule / agent-tools / outbound / handoff),每一项都配has()降级,是本文所有代码片段的来源。 - lottery(
plugins/utils/lottery/plugin.ts):生产级插件——数据库优先用databaseHostToken、缺省落内存实现;provide自有 token 给命令复用;Agent 工具走await import()惰性加载,保证 IM-only 安装不引入@zhin.js/agent;cron 每日流水线。
lottery 的装配骨架值得抄:
// 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/runtime(packages/im/runtime/src/manifest.ts),非法清单直接抛 ManifestValidationError。
{
"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 |
type | 是 | plugin(可挂载子插件、可声明 features/runtime)或 feature(平台能力提供者) |
entry | 是 | 插件/功能入口文件,包相对路径,./ 开头且不得含 .. |
engine | 否 | semver range,对 Runtime 提供的 engine 版本做 satisfies 校验 |
runtime | 否(仅 plugin) | isolated 表示在隔离运行时中执行:仅限子插件(Root 不可用)、不得挂载 Host Feature,且需要 isolation adapter 支持 |
platformFeatures | 否(仅 plugin) | 默认 true:Root 插件即使不声明也会获得官方 Stable Features(@zhin.js/adapter、command、component) |
features | 否(仅 plugin) | 依赖的 Feature 包:package 支持 npm 包名或 ./ 相对路径(monorepo 本地);api 是对该 Feature 声明的 featureApi 版本的 semver 要求;optional 标记缺失可降级 |
plugins | 否(仅 plugin) | 挂载子插件实例:package 同 features 的包名规则;instanceKey 必填,匹配 ^[a-z0-9][a-z0-9-]*$,是实例隔离与配置的键——同一包挂多个实例就靠不同 instanceKey,实例配置写在 app 的 zhin.config.yml 的 plugins.<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:
{
"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 拒绝对依赖包执行类型剥离。