04 · 类型化事件:五种分发模式

目标:掌握插件间不共享服务的通信方式——事件。重点吃透 waterfall 的短路语义。 对应快速入门项目:npm run lesson:04

核心机制

  • 服务支持直接调用;事件让插件无需知道谁在监听就能发出通知(harness 用事件处理工具结果、模型请求、审批决定)
  • 事件名与监听器签名走 interface Events 声明合并:emit/on 因此获得完整类型
  • ctx.on() 属于 effect——监听器随插件一同消失,绝不需要手动 removeListener
  • namespace/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

五种分发模式速查

模式调用语义
emitctx.emit(name, ...args)同步广播;不等待、不收集返回值
parallelawait ctx.parallel(name, ...args)所有监听器并发运行,一同等待
serialawait ctx.serial(name, ...args)按序执行;第一个非 null/false/undefined 返回值胜出并短路
bailctx.bail(name, ...args)serial 的同步版
waterfallctx.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(策略代替用户作答)等决策事件。

动手练习

  1. 给 demo/transform 再加一个不调 next() 的监听器,体会短路吞掉下游的杀伤力
  2. 把监听器 1 改成不调 next() 直接返回 'intercepted',观察监听器 2 是否还会运行(答案:不会——短路即终止链条)
  3. 写一个用 serial 的事件:两个监听器,第一个返回 undefined,第二个返回 'picked'——验证首个有效返回值胜出

上一课:03-services | 下一课:05-config-diagnose