04 · 类型化事件:五种分发模式
目标:掌握插件间不共享服务的通信方式——事件。重点吃透 waterfall 的短路语义。 对应快速入门项目:
npm run lesson:04
核心机制
- 服务支持直接调用;事件让插件无需知道谁在监听就能发出通知(harness 用事件处理工具结果、模型请求、审批决定)
- 事件名与监听器签名走
interface Events声明合并:emit/on因此获得完整类型 ctx.on()属于 effect——监听器随插件一同消失,绝不需要手动 removeListenernamespace/action命名约定(如stats/report、tools/result)
实例 A:emit 广播(服务 + 事件源 + 监听方)
// stats.ts —— 计数服务,每次变化通过 emit 广播
import { Service, type Context } from 'cordis'
declare module 'cordis' {
interface Context {
stats: StatsService
}
// 事件名与监听器签名也走声明合并
interface Events {
'stats/report'(name: string, count: number): void
}
}
export class StatsService extends Service {
private counts = new Map<string, number>()
constructor(ctx: Context) {
super(ctx, 'stats')
}
bump(name: string) {
const next = (this.counts.get(name) ?? 0) + 1
this.counts.set(name, next)
this.ctx.emit('stats/report', name, next)
}
}
export const name = 'stats'
export function apply(ctx: Context) {
ctx.plugin(StatsService)
}// reporter.ts —— 监听方
import type { Context } from 'cordis'
import type {} from './stats.ts' // 仅导入类型,让 TS 看到声明合并;运行时不导入任何内容
export const name = 'reporter'
export const inject = ['stats']
export function apply(ctx: Context) {
ctx.on('stats/report', (name, count) => {
console.log(`[stats] ${name} -> ${count}`)
})
ctx.stats.bump('tool_call')
ctx.stats.bump('tool_call')
ctx.stats.bump('prompt')
}运行 npm run lesson:04:
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1
五种分发模式速查
| 模式 | 调用 | 语义 |
|---|---|---|
| emit | ctx.emit(name, ...args) | 同步广播;不等待、不收集返回值 |
| parallel | await ctx.parallel(name, ...args) | 所有监听器并发运行,一同等待 |
| serial | await ctx.serial(name, ...args) | 按序执行;第一个非 null/false/undefined 返回值胜出并短路 |
| bail | ctx.bail(name, ...args) | serial 的同步版 |
| waterfall | ctx.waterfall(name, ...args, next) | 环绕中间件:监听器收到 (...args, next),可包装 next() 返回值,也可不调 next() 直接返回(短路/否决) |
实例 B:waterfall 转换与短路
// waterfall-demo.ts
import type { Context } from 'cordis'
declare module 'cordis' {
interface Events {
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
}
}
export const name = 'waterfall-demo'
export function apply(ctx: Context) {
// 监听器 1:包装下游结果
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
// 监听器 2:拥有决策权时短路
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
void (async () => {
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
})()
}输出:
HELLO
** BLOCKED **
第二行的执行链:监听器 1 先运行并调 next() → 触发监听器 2 → 监听器 2 看到 blocked 直接返回,不调 next() → 最内层默认逻辑(传给 ctx.waterfall 的函数)从未运行 → 返回途中监听器 1 再把替换消息转大写。
**waterfall 纪律:只观察/标注的监听器必须调 next(),不调就是有意短路。**一个忘记调 next() 的日志监听器会悄无声息地吞掉所有下游默认行为。harness 用它做 agent/request(替换模型调用配置)、approval/request(策略代替用户作答)等决策事件。
动手练习
- 给
demo/transform再加一个不调next()的监听器,体会短路吞掉下游的杀伤力 - 把监听器 1 改成不调
next()直接返回'intercepted',观察监听器 2 是否还会运行(答案:不会——短路即终止链条) - 写一个用
serial的事件:两个监听器,第一个返回undefined,第二个返回'picked'——验证首个有效返回值胜出
上一课:03-services | 下一课:05-config-diagnose