EventBus — 事件
事件是 core 的单向通知原语。可拦截、可改写数据的中间件钩子不在 core,由插件 @aalis/plugin-hooks 提供,契约与用法见 api-hooks。
EventBus — 事件总线
源码: packages/core/src/primitives/events.ts
类型安全的全局发布/订阅事件总线,用于松耦合的异步通知。事件只通知、不干预流程。插件在 uses 里声明 events 后,经 events.use 拿到按激活绑定的 on / emit。
API
// 监听事件(返回 dispose 函数;随这次激活撤回)
const off = events.use.on('inbound:message', async (msg) => { ... });
// sticky 事件:注册晚于发出也能在下一个微任务收到补发;派发进行中登记的排在本轮末尾送达,要等前面的监听器返回或超时。每个监听器只收到一次('app:ready' / 'app:started')
events.use.on('app:ready', () => { ... });
// 发出事件(按注册顺序依次 await 每个 handler,永不拒绝)
await events.use.emit('outbound:message', outMsg);实现特性
- 异步串行:
emit()会 await 每个 handler 完成后再执行下一个 - 按注册顺序调用
on()返回 dispose 函数,可随时移除监听- 每次
on()是一条独立登记:同一函数登记两次触发两次,各自的 dispose 只移除自己那条;两次激活共用同一函数也互不影响 - 激活关闭时自动移除这次激活登记的所有监听,但不等已经在执行的处理器:处理器里的异步工作在本激活收尾、清理期间可能仍在跑。要在关闭前完成的,自己记下并在
onDrain里等;要停下的,交给lifecycle.use.signal取消。处理器另起、没有交还给emit的 Promise,emit不等,它的拒绝也不经监听器的错误隔离
core 内置事件
core 自持的只有下面八个基础设施事件(源码 packages/core/src/types/events.ts)。消息、工具、会话、 调度等业务事件不在 core,由各 -api 包经 declaration merging 注入——谁注入了哪一族见 扩展点索引 §2,键与 payload 的权威定义在该包的 declare module 声明里。
八个事件按「发射方等不等监听器」分两节,节由前缀判定:
屏障(app:*):emit 是 App 生命周期方法里的一步,逐个等监听器返回后才推进下一步。单个监听器至多等 AppOptions.slowThresholdMs(默认 60000;0 = 不设限):超过即 warn 点名登记者(插件实例 id,宿主为 root),不再等它、转向下一个,它之后的拒绝照常按监听器抛错上报。下表「全部返回」均按此理解。app:stopping 的时机见下表与后文。
| 事件 | 参数 | 时机 |
|---|---|---|
app:ready | — | 启动第一相位(sticky) |
app:started | — | 启动第二相位:全部 app:ready 监听器完成之后(sticky);CLI / TUI 在此接管终端 |
app:restarting | — | restart() 先发本事件,监听器全部完成后才把控制交给宿主的 RestartStrategy |
app:stopping | — | stop() 先 beginShutdown()(丢弃排队的重算)再 idle()(等待已在飞的重算与手动 dispose 段)之后发出;监听器全部返回后才执行停机计划。再次调用 stop() 返回完整停机的同一 Promise,监听器不能 await 或返回它 |
通知(service:* / plugin:* / plugins:changed):发射方不等监听器。发射点要么是同步的注册 / 拆卸收尾, 要么在 PluginManager 的 recompute flight 或挂起段内——那里等监听器会与 plugins.idle() 互等死锁。 监听器因此不能假设「我返回了状态机才继续」;要看落定后的状态请 await plugins.idle()。
| 事件 | 参数 | 时机 |
|---|---|---|
service:changed | name | 该服务的提供者或选中的那一家可能变了,请重读(list / get / all)。provide、退订、激活拆卸、激活转入后台与完成后上线、偏好切换(services.use.prefer / unprefer)都发;可能变化时就发,不保证恰好一次,也不说明变了什么。core 自己的跟随(follow、registrar)与重算不靠它,由变更那次调用同步驱动 |
plugin:loaded | instanceId | 插件实例已激活;同一轮 recompute 可能紧接着激活下一个插件 |
plugin:unloaded | instanceId | 插件实例已拆卸;只发给发过 plugin:loaded 的激活:从未激活完成的(激活失败的回滚、仍在初始化时被拆)不发,关机拆卸也不发 |
plugins:changed | — | 一轮 recompute 收敛,插件状态集合可能已变;关机轮不发 |
app:ready 与 app:started 是两个相位,不是同一里程碑的两个名字:start() 串行 await,app:started 晚于 全部 app:ready 监听器完成(或超过阈值被放弃等待)。start() 不等转入后台的插件激活,它们在 apply 里挂的 app:ready / app:started 监听器可能在自己的 apply 完成之前被调用;需要自身就绪的逻辑放在 apply 末尾再挂 (两个事件都是 sticky,晚挂照样收到),或在监听器里自查就绪。
后台激活期间它登记的服务不对外:转入后台、激活成功上线时对它名下的服务各发一次 service:changed;后台期间的登记、 退订与失败后的拆卸照常发,此时重读看不到它的条目。
app:stopping 用于知会(打印告别语、切状态条),不是清理通道——清理副作用一律走 lifecycle.use.onDrain / lifecycle.use.onDispose,它们覆盖 bounce / unload / 停机全部路径。总线上没有 dispose 事件。从 beginShutdown() 起,新发起的 register / unload / enable / disable / bounce / updateConfig 都立即返回 false,不改条目状态或配置,也不加入额外拆卸;已有停机计划负责清理。stop() 在任何阶段都返回完整停机的同一 Promise。监听器不能 await stop() 或返回它,否则停机与正在执行的屏障监听器会互等;调用 void stop() 不会重复启动停机。
扩展自定义事件
第三方插件通过 TypeScript declaration merging 即可为事件系统新增类型安全的自定义事件:
declare module '@aalis/core' {
interface AalisEvents {
'scheduler:tick': [jobId: string];
'scheduler:error': [jobId: string, error: Error];
}
}
// 之后可以类型安全地使用
events.use.on('scheduler:tick', async (jobId) => { ... });
events.use.emit('scheduler:tick', 'job-1');AalisEvents 是类型封闭的(没有 [key: string] 兜底):对扩展开放、对拼写错误封闭—— 未声明的事件名在 events.use.on / events.use.emit 处直接编译报错,契约始终可枚举、依赖边在包图中可见。
声明的事件,除了 plugin-workflow 拒绝表里的,都能被编排的 event 触发器当成触发源:事件参数插值进编排的节点,按编排定义者的身份运行。 拒绝表只在 plugin-workflow 里,只列第一方声明的事件(见 plugin-workflow): 第三方插件声明的事件,负载里带会话内容(会话 id、平台、正文等)的也会被当成触发源,声明它的插件拦不住。所以第三方插件 声明事件时,请在说明里写明负载里有什么,由定义编排的人自己判断能不能订阅。
事件名需要运行时动态生成(如按频道/任务 ID 派生)时,官方出路是在自己的命名空间内 合并一条模板字面量签名(TS 4.4+):
declare module '@aalis/core' {
interface AalisEvents {
// 动态事件名族:myplugin:channel: 前缀下的任意后缀都合法,payload 类型统一
[k: `myplugin:channel:${string}`]: [payload: ChannelMessage];
}
}
events.use.on(`myplugin:channel:${channelId}`, async (msg) => { ... }); // msg: ChannelMessage同前缀下更具体的字面量 key 仍可逐条声明(TS 优先匹配字面量)。前缀必须用自己插件的 命名空间,避免与他人模板签名相互吞并。