Skip to content

插件定义与能力 ​

插件与框架的交界面是一份定义对象和一次激活上按声明装配的能力,不是一份公开的执行上下文。

源码: packages/core/src/composition/plugin-definition.ts、packages/core/src/composition/descriptors.ts、packages/core/src/composition/binding.ts、packages/core/src/composition/core-services.ts

定义对象 ​

typescript
import { definePlugin, events, logger, provide, lifecycle, optional } from '@aalis/core';
import { memory } from '@aalis/api-memory';

export default definePlugin({
  name: '@scope/plugin-example',
  displayName: '示例',
  uses: { events, logger, provide, lifecycle, memory: optional(memory) },
  apply({ events, logger, provide, lifecycle, memory }) {
    logger.use.info(`激活 ${lifecycle.use.id}`);
    const off = events.use.on('app:started', () => logger.use.info('已启动'));
    lifecycle.use.onDispose(off);
  },
});

definePlugin 在模块加载时校验声明表(name 合法、uses 每一项都是描述符或 optional() 包装)并原样返回,好让 apply 的参数类型从 uses 推导出来。加载器只接受 default 导出的定义对象。

name 须为 trim 后非空的字符串,不含实例后缀 :suffix(只用于 register 的 instanceId),也不能是 __proto__ / constructor / prototype:这一条只保证 id 写进普通对象当键(配置文档、管理面索引)时安全,读取继承属性仍要用 Object.hasOwn。instanceId 同规则(允许 name:suffix)。手写、未经 definePlugin 的对象在 register 还会再过同一道闸:校验失败(含定义里有另一份 @aalis/core 造的描述符或 optional 包装)返回 false 并记 error,不抛错。定义期 definePlugin 抛错;登记期不抛、返回 false。

字段:

字段说明
name插件名,与 package.json 的 name 一致;单实例时即实例 id
displayName / subsystem展示元数据。core 不读、不校验 subsystem 取值
uses用到的全部能力。键是 apply 的参数名,值是描述符;可选依赖包一层 optional()。没有默认注入
provides本插件提供的服务描述符列表。激活后按本次激活实际登记的条目校验确已 provide(以任何逻辑身份登记的都算,别的激活登记的同 id 条目不算);未提供则本次激活进入 error
reusable允许同一份定义以 name:suffix 多次注册;默认 false
apply(caps)拿到各项选择层后的装配。可返回 Promise

PluginMeta 是空接口:core 对额外字段零感知,只原样带在定义上。配置表单(configSchema)由 @aalis/schema-config、对 core 的扩展声明(extends)由 @aalis/api-webui 经 declaration merging 挂进来。详见 types.md。

uses 与装配 ​

声明即装配:apply 只收到 uses 里声明的各项。uses 说明插件依赖什么,激活闸与关停编排据此安排;它不是授权边界,插件是同进程的可信代码,经 services 能动态查到任何服务(见 安全模型)。Core 基础服务与第三方服务共用容器与装配路径——都是描述符,放进 uses,每一项装配成一个选择层,业务层由各自的 bind 生成。

typescript
import { definePlugin, optional, events, logger, provide } from '@aalis/core';
import { tools } from '@aalis/api-tools';
import { memory } from '@aalis/api-memory';

export default definePlugin({
  name: '@scope/plugin-example',
  uses: {
    events,
    logger,
    provide,
    tools,                 // required:未就绪则顶层插件保持 pending
    memory: optional(memory),
  },
  apply({ tools, memory }) {
    tools.use.register({ /* ... */ });
    if (memory.present) {
      // optional:可能选不到提供者,先看 present 再用 memory.use
    }
  },
});
  • required(未包 optional 的服务):参与激活闸。缺席则顶层插件 pending,恢复后重新激活。
  • optional:不参与激活闸;选择层与业务层与 required 完全相同,只是激活闸这一条不同。
  • Core 基础服务(events / logger / config / lifecycle / provide / services):启动时由根激活经 provide 独占登记,与第三方服务同一条路径;提供者按调用方身份交出属于该激活的接口,声明同样计入 required / optional。它们在加载插件前已经可用;不声明也不影响框架管理资源,只是 apply 拿不到对应接口。见 内置服务的登记。
  • 声明即计入关停编排(required 与 optional,访问与否无关)。

optional() 使用同版本 Core 副本可识别的包装标记;描述符自有 optional 字段不算。

Core 默认登记的服务 ​

描述符业务层(caps.x.use)作用
eventsEventson / emit,监听随这次激活撤回
lifecycleLifecycleCapid、signal、onDrain、onDispose
loggerLogger这次激活的日志器
config只读对象这次激活的插件配置视图。配置文档是宿主提供的另一项服务,见下方 host-config
provideProvide唯一的服务发布入口
servicesServices动态查询与偏好。查到的不是声明依赖

六项的业务句柄在装配时就取好这次激活的门面:根是唯一的提供者、活得比插件长,本次激活拆卸之后门面照常可用(logger 不抛、config 照常可读)。对根以外的 id 调 of 当场抛错。

钩子(hooks)与贡献点(contributions)不是 Core 基础服务,由插件 @aalis/plugin-hooks / @aalis/plugin-contributions 提供,描述符从 @aalis/api-hooks / @aalis/api-contributions 导入,见 api-hooks、api-contributions。

宿主管理面另有两个服务,同样由根激活独占登记、通过 uses 声明:

描述符服务名说明
appServiceapp停机、重启
pluginsServiceplugins插件管理动作与状态

宿主还可以在根上提供自己的服务,core 不保证它们在场,插件宜以 optional 声明并在缺席时降级。Node 宿主 @aalis/runtime 的 startAalis 提供四项:

描述符服务名契约包说明
hostPathshost-paths@aalis/api-host-paths数据目录、安装集与数据目录布局的绝对路径,见 api-host-paths
hostLogshost-logs@aalis/api-host-logs读日志:最近的历史与此后的实时日志,订阅随激活撤回,见 api-host-logs
hostConfighost-config@aalis/api-host-config配置文档的读写与 save(),见 运行态与配置文档
pluginSourceplugin-source@aalis/api-plugin-source热扫描:rescan() 重新发现并登记新插件

provide ​

typescript
import { definePlugin, defineService, provide } from '@aalis/core';

interface Greeter {
  hello(name: string): string;
}
const greeter = defineService<Greeter>('greeter');

export default definePlugin({
  name: '@scope/plugin-greeter',
  uses: { provide },
  provides: [greeter],
  apply({ provide }) {
    provide.use(greeter, { hello: name => `hi ${name}` }, { priority: 10, label: 'default' });
  },
});

实现按描述符的提供者类型约束,容器保存的就是这个对象。返回的退订同步撤回这一条登记,重复调用无副作用;未退订的登记在提供方激活关闭时整体撤回。选项:

  • priority / label:解析序与展示
  • exclusive:独占这个服务名;与已有登记冲突、或该条目存在期间添加其他提供者,都会抛错。Core 基础服务与第三方使用同一规则
  • entryId:条目的逻辑身份(偏好、展示与 of(id) 认它),缺省为本激活 id。一个激活登记同一服务的多条时各给一个,list() 与 of(id) 才分得清;托管自己不运行代码的包(如 WebUI 服务端替扫描到的前端包登记)时取被托管包的 id。格式由服务契约约定,core 不检查;清理与 provides 核对仍归本激活

dev 模式下,实际注册了但未写入 provides 的服务会 warn:启动拓扑使用声明清单,遗漏会影响排序;关停依赖则按实际提供者身份建立。生产宿主应显式传入 AppOptions.devMode: false。

选择层与业务层 ​

apply 收到的每一项都是一个选择层 ServiceSelection<B>:由 core 定义、每个服务都一样,只管选哪一家;B 是业务层,由契约定义。required 与 optional 拿到的是同一接口。

typescript
interface ServiceSelection<B> {
  list(): ProviderEntry<B>[];  // 对外可见的提供者,每次调用重新枚举;本插件选中的那一条在前
  readonly use: B;             // 默认业务句柄:转发给本插件当前选中的那一家
  of(id: string): B;           // 固定某一家的业务句柄
  readonly present: boolean;   // 本插件此刻能否选到一家
  follow(attach: (handle: B) => void | (() => unknown)): () => void;
}

interface ProviderEntry<B> {
  readonly id: string;         // 条目的逻辑身份(contextId)
  readonly label?: unknown;    // 展示值,core 原样转交
  readonly priority: number;
  readonly selected: boolean;  // 是否为本插件当前选中的那一家
  readonly basis?: 'plugin' | 'global' | 'rank'; // 选中依据,只在选中的那一条上有
  readonly use: B;             // 钉在这一条登记上的业务句柄,见下文
}

契约不给 bind 时,业务层就是按提供者形状转发的活句柄;给了 bind 时是 bind 的返回值(如 tools 的 register、webuiServer 的 definePage)。

挑选规则只有一条:本插件偏好 > 全局偏好 > 优先级 > 注册顺序。偏好指向的那家不在就按下一级挑,回来后切回。转入后台、尚未完成激活的提供者不参与。

业务句柄是活的转发句柄:

  • use 每次访问成员都重新解析到本插件此刻选中的那一家,存进字段或闭包也会跟着换人;已经开始的一次调用在原来那一家上完成。
  • 选不到提供者时,访问转发到提供者的成员同步抛 ServiceUnavailableError,与成员本身同步还是异步无关。错误类不导出,按 err.name === 'ServiceUnavailableError' 或 err.code === 'service-unavailable' 认(另带 service,指名或钉住的那一家给 provider);可选依赖先看 present,要「选不到就当没有」写 caps.x.present ? caps.x.use : undefined。required 依赖丢失到调度收敛之间也可能短暂选不到。契约 bind 定义的成员按契约行事:经 registrar 的登记方法(tools.use.register、commands.use.command、webui.use.definePage、hostLogs.use.subscribe 等)缺席时照收、上线后挂上,写成 async 函数的(如 hooks.use.run)得到被拒的 Promise。
  • of(id) 按 id 解析:那一家不在时访问成员抛错,同一个 id 回来后(含换了新实例)照常可用。
  • list() 条目的 use 钉在那一条登记上:它对外时打到它,撤回之后访问抛错,同一个 id 换了新实例也不跟过去。同一条登记每次列出都是同一个句柄。
  • 一段操作(先读后写、先查后删、算向量与记下模型标识)要落在同一家上,取选中那一条的句柄:const v = caps.x.list().find(p => p.selected)?.use,选不到时是 undefined;要选不到时照样抛不可用错误,写 ?? caps.x.use。途中那一家撤回,后续访问抛错,不会落到另一家上。比较身份就比条目 id,如 caps.trigger.list().find(p => p.selected)?.id === caps.lifecycle.use.id。
  • 要用提供者建立长期状态(SDK 句柄、订阅)走 follow。

句柄的边界 ​

活句柄是一层代理,只转发属性读取与方法调用,以下几处与直接持有实例不同:

  • 不给 bind 时 typeof 句柄是 'function',提供者本身是函数时句柄可以直接调用。给了 bind 的句柄是 bind 的返回值:按 Object.create(port.provider) 加成员的写法,typeof 为 'object'。
  • then、symbol 键(含 Symbol.iterator / Symbol.asyncIterator)与 toJSON 不转发:await 句柄得到句柄本身,句柄不能 for…of 或展开迭代,打印与 JSON.stringify 不触发解析。
  • in、Object.keys、对象展开看不到提供者的成员(不给 bind 时什么成员都列不出,给了的只列出 bind 自己加的可枚举成员),structuredClone 句柄抛错;在句柄上写属性落不到提供者。
  • 每次读方法得到一个新的包装函数(h.m !== h.m),调用时才再解析并以真实实例作 this,用 # 私有字段的提供者照常工作。包装函数不能 new:提供者成员里的类经句柄取出后无法构造,需要构造的由提供者给工厂方法。
  • 返回 this 的方法交出的是真实实例,它不再跟着换人。
  • 缺席时 String(句柄) 同样抛错(它要读 toString)。

自定义 bind 要在转发之上加或改几个成员时,写 Object.assign(Object.create(port.provider), { ... }):没覆盖的成员沿原型链落到活句柄上。不要用对象展开去拼——展开看不到提供者的成员。

follow ​

跟随选中的提供者建立有状态资源,取代整插件重启式的依赖更新。选中的那一家换人不一律重启消费者。

typescript
apply({ llm, lifecycle }) {
  const stop = llm.follow(handle => {
    const client = handle.connect();
    return () => client.close(); // 同步返回清理函数;清理本身可以是异步的
  });
  lifecycle.use.onDispose(stop);
}
  • attach 必须同步返回 void 或清理函数。返回 thenable 会被接住并 warn,该 Promise 的拒绝不会逃逸,也不会被当成 cleanup。
  • 选得到即以选中那一家的业务句柄调 attach;换人时先跑上次返回的清理,等它的 Promise 落定(完成或被拒——被拒只记 warn,不代表资源已释放)之后才以新选中那一家的句柄调 attach。选不到时只清理。
  • 交给 attach 的句柄钉在挂上的那一条登记上:那一家撤回、或同一个 id 换了新实例,它仍打到原来那一家,直到这次挂载的清理落定,之后访问抛错。
  • 等待期间再换人只跟到最新的。关闭或退订之后不再挂载,哪怕旧清理后来才落定。
  • 旧清理永不落定则新的永不挂上;关闭时按超时放弃并点名。
  • 选中的那一条不变则不重挂。

registrar(注册型能力)与 follow 不同:换人时立即在新提供者重挂,旧异步撤回可后台进行,关闭会等它。不能声称所有新旧资源绝无重叠。见 枢纽服务。

动态查询(services) ​

typescript
apply({ services }) {
  const online = services.use.get(llmDesc) !== undefined; // 查在不在;有描述符则带类型
  const entries = services.use.all(name);                 // 运行期字符串(URL、配置里的服务名)的全部提供者
  const rows = services.use.inspect('llm');               // 只读元数据,展示与对外输出用
}

插件的业务调用只经 uses 声明的选择层。get / all 只用来查某个服务在不在、列出运行期服务名的提供者,以及取身份键(如按宿主实例记账);声明会成环或自依赖、只能动态查的,在调用处写明原因。展示与对外输出(管理面列表、HTTP 回包、日志)用 inspect,不用 all():后者的条目带实例,直接序列化会把提供者连同内部状态(如密钥)一起带出去。

get / all 给的是原始实例,按全局口径解析(不看本插件偏好),不享有业务句柄的转发语义。查到的服务不是声明依赖:不参与激活闸,不自动重绑,不产生依赖边,关停期可能拿空。需要声明等待与跟随就写进 uses。动态查内置服务拿到的是提供者函数,不是门面:以调用方自己的身份调用才得到它自己的接口,认不出的身份即抛错。声明的依赖在单独 unload / disable / bounce 提供者时也享有与整机停机同样的交接保证。

services.use.inspect(key, { for? }) 只读登记元数据(contextId / priority / label / exclusive,选中的那一条另带 selected 与 basis),不含实例,可以放心序列化:给 for(插件实例 id)即按那个插件的解析顺序排列,与它自己的 list() 同一条规则;不给即全局口径。

prefer(key, contextId, { for }) / unprefer / preferred 管理偏好:给 for(插件实例 id)是那个插件的本插件偏好,不给是全局偏好。偏好只由用户经管理面写(WebUI、配置),普通插件不调。

生命周期 ​

lifecycle.use.id 是这次激活的实例 id(多实例为 name:suffix):日志、展示、路由用的逻辑名,不是资源身份。归属用不透明激活身份,不从 id / entryId 字符串前缀猜。

signal ​

lifecycle.use.signal 是这次激活的取消信号(AbortSignal),用来中止 apply 里发起的长任务,也可在定时器、重连等回调里查 signal.aborted 判断本激活是否已开始关闭:

typescript
async apply({ lifecycle }) {
  const res = await fetch(url, { signal: lifecycle.use.signal });
  // ...
}

两个 abort 时机:

  • apply 尚未完成时被停机或管理动作(disable / unload / bounce,或它的 required 提供者被这样处理)接手,或后台激活因 required 依赖下线被拆:关闭计划冻结完成后立即 abort。
  • 其余情形:在本激活的收尾段开始时 abort,onDrain 回调执行时已断。依赖方先关闭时,提供者的 signal 仍未断。

reason 是 name 为 'AbortError' 的 DOMException。abort 监听器同步执行,不得抛错:监听器的异常由宿主运行时按未捕获异常处理(Node 下 runtime 会因此退出进程)。

onDrain / onDispose ​

typescript
lifecycle.use.onDrain(async () => {
  await persistPendingWork(); // 停接新活、把在手的数据交给下层并等它确认
}, 'flush');

lifecycle.use.onDispose(async () => {
  await conn.close();
}, 'db');
  • onDrain(收尾段,最先执行):此刻本激活的监听、登记与依赖都还在。依赖交接(把最后一笔交给还活着的提供者)放这里。
  • onDispose(清理段):本激活的对外登记已撤回;依赖可能已不可用,只释放自己的资源。
  • 都可以返回 Promise。编排层在 unload / bounce / 停机路径上走分阶段关闭,会等待异步清理。
  • label 仅进诊断日志,超时或抛错时点名。

不要用 events.use.on('app:stopping', …) 做资源清理——那只在 app 全局停机时触发一次,不会在 bounce / unload 时触发。该事件定位是知会,不是清理通道。见 events.md。

关闭一律异步:关闭位在发起关闭时同步设置,onDrain、撤回与 onDispose 不在发起关闭的调用方的栈上执行(abort 监听器除外)。关停编排以激活为单位,分收尾(drain)与关闭(close)两阶段。边只来自框架管理的关系:声明的依赖(required 与 optional,含尚未访问的 optional)在编排那一刻对外的全部提供者,以及存活的托管绑定与尚未落地的撤回。不追踪动态查询拿到的原始实例。

承诺按依赖形状分三种,不是一条无条件规则:

  1. 插件用别的插件的服务(声明的 required 与 optional,对外的全部提供者):消费者整个 close 完,提供者才 drain。
  2. 根激活使用插件的服务(宿主经 app.bind 取用):根 drain 先于该插件的 close,根 onDrain 期间该插件尚未关闭(可能已 drain)。
  3. 插件使用根激活登记的服务(基础服务、宿主服务,以及宿主经 app.bind({ provide }) 发布的服务):不加边,归属保证插件 close 先于根 close;这不保证根还没 drain。

环:先只让仅被 optional 边挡着的成员 drain(不告警),required 消费者仍先关完;环全由 required 边构成时,环内成员先全部 drain 再关并告警:drain 期间双方都能调用对方,onDispose 时提供者可能已收尾。环外与归属约束不松。单独 unload / disable / bounce 提供者时,正在用它的 required 下游(传递闭包)并入同一批关闭,上述交接同样成立;提供者因 required 依赖丢失退场时,随它一起失去全部提供者的 required 下游同样并入同批(还有后备提供者的不算,别的计划正关着的提供者不算后备);这覆盖动作发起时处于 active 或仍在初始化(在飞或后台)的 required 下游;并发管理动作里已在收尾、依赖本次要关的插件所提供服务的下游,本次动作等它关完才让提供者收尾(不另设时限:那份计划逐项受同一上限约束,总会关完);动作期间才开始激活的下游、动作进行中发生的停机,与本次动作彼此不排序。这里保证的是框架的调用顺序与等待:插件若在 drain 中自行撤回服务或关闭连接,框架无法维持该实现可用;业务交接仍须返回可等待的 Promise,并处理失败。

关闭回调不能等待同一计划中排在自身之后的阶段。例如在 onDrain 中 await app.stop()(或返回这个 Promise):停机要等这次收尾完成,收尾又在等停机完成,两者就会互等。收尾应等待数据交接本身完成,再由计划继续执行后续阶段。计划外的调用者仍可 await app.stop() 等实际关闭完成;重复请求加入已有关闭,不改变计划顺序,也不会提前兑现。对本插件所依赖的提供者(optional 也算)做管理动作同理:那份计划要等本插件关完才让提供者收尾,在 onDrain 或 onDispose 里 await 它会互等到清理宽限。

单个异步清理项的等待上限由 AppOptions.disposeTimeoutMs 注入(默认 5000;0=不设限):超时放弃该项、继续后续清理并 warn 点名;该项此后才到的拒绝照样按清理失败上报。超时只是停止等待,不代表资源已释放。

apply 尚未完成就被关闭的激活,signal abort 之后自其收尾段开始至多再等 disposeTimeoutMs 让 apply 落定;到期不再等待,后续清理照常执行,apply 本身继续跑(迟到的登记被拒收、onDispose 就地执行)。超过宽限没落定的 apply 与清理都记在实例 id 名下,它们落定之前同一 id 不起新实例(getStatus() 的 lingering,见 plugin.md)。激活超过 AppOptions.slowThresholdMs(默认 60000)仍未完成时转入后台,重算不再等它;app:* 屏障事件的单个监听器至多等同一阈值。两个值都非 0 时 register() / stop() 不会被永不落定的 apply 或屏障监听器挂住;任一为 0(不设限)时仍可能等待不返回。

宿主入口 ​

宿主不拿插件那份绑定。App 提供:

  • app.plugin(definition, config?, instanceId?, options?) / app.pluginAll(items):注册定义对象,配置与禁用标记由宿主传入
  • app.bind(uses):按与插件同一套描述符为根激活装配各项选择层(如 app.bind({ provide }).provide.use(...)),登记归属根激活、随 App 停止撤回
  • app.plugins / app.logger

插件拿的是自己激活的绑定,不复用 app.bind 的那一份。

内部职责 ​

旧 Context 类已拆解。服务装配、资源管理与插件编排按职责组织,所有服务共用描述符、容器和绑定路径:

部分职责
kernel/DisposableChain逆序执行、逐项限时与异步等待,等不到的异步清理交给宿主记账;不认识服务或插件
infrastructure/Resources一次激活的资源账与关闭过程:收尾、撤回、清理三条链,初始化等待,在飞工作的账本;每段收口等本段发起的工作落定,关完仍没落定的交给编排层按 id 记着
orchestration/Activation内部身份、配置视图、资源记录、根名下的插件激活和依赖边;不提供原语操作门面
orchestration/ActivationHost创建激活、经根激活登记内置服务、按 uses 装配能力;关闭时按归属同栈切断本激活的原语登记
composition/core-services内置六项的描述符与提供者:提供者按调用方身份交出这次激活的 events / provide 等接口
composition/descriptors、plugin-definition服务与插件定义、类型推导,以及资源口契约
composition/binding选择层与活句柄、资源口:观察选中的提供者变化,负责 follow 交接、registrar 登记与逐条撤回

插件只使用 apply 收到的能力,宿主通过 app.bind 装配根激活的能力。上表中的内部类不属于公开 API,包的 exports 也不开放它们所在的深路径。

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布