Contexto: o daemon usa eventos internos pra comunicação desacoplada entre módulos
(Monitor → Recorder no primeiro caso, mas outros virão). Hoje o ChannelMonitor
mantém uma lista privada de listeners e implementa on() + emit() na própria
classe. Isso é o mínimo viável — mas conforme o número de produtores/consumidores
cresce, tem uma trajetória natural de refactor. Documento aqui os três estágios
pra saber quando promover.
on() + emit() locais no produtor (estado atual)Cada classe que emite eventos guarda os próprios listeners. O array de listeners,
o on() público, e o emit() privado (com try/catch por listener) vivem dentro
da classe.
export class ChannelMonitor {
private listeners: MonitorListener[] = []
on(listener: MonitorListener) { this.listeners.push(listener) }
private async emit(event: MonitorEvent) {
for (const listener of this.listeners) {
try { await listener(event) }
catch (err) { console.error('listener failed', err) }
}
}
}Quando fica bom:
main.ts) faz o wiring explícitoQuando começa a doer:
Emitter<T> genérico reusávelQuando o padrão começa a se repetir em 2-3 classes diferentes, extrai a mecânica
pra um utilitário. Cada classe passa a conter um Emitter<T> em vez de
gerenciar o array/try-catch na mão.
E o Monitor vira:
Ganho: mecânica deduplicada. Comportamento (isolamento de erro, ordem) uniforme em todos os emissores.
Ainda não é bus: cada Emitter é local à sua classe. Ninguém "faz assinatura global" — o consumidor precisa ter referência ao emissor.
Quando fica bom:
Quando começa a doer:
EventBus centralQuando a rede de produção/consumo vira muito emaranhada, sobe pra um bus único
que roteia por tipo de evento. Cada evento é uma classe (ou tagged union
com discriminador tipo type: 'live'), e o roteamento usa esse marcador pra
saber quem chamar.
Como o padrão se parece na prática:
Eventos são classes — não interfaces, porque interfaces desaparecem em
runtime e o roteador precisa de uma identidade concreta pra usar como chave
no Map.
Existe uma única instância de EventBus, criada no composition root
(main.ts) e injetada em produtores e consumidores.
Produtores publicam sem saber quem escuta:
Consumidores assinam pela classe do evento:
O que muda em relação ao Emitter local:
bus
e o tipo do evento.Cuidados que não são triviais:
ChannelLiveEvent, você tem
que grep-ar todas as chamadas bus.subscribe(ChannelLiveEvent, ...). IDE
ajuda, mas não é Cmd+Click no produtor pro consumidor.publish(). Try/catch por handler no publish.for + await) é debugável mas lenta;
paralela (Promise.allSettled) é rápida mas ordem indefinida. Escolher
conscientemente por caso de uso.publish — decisão
explícita.subscribe usa EventClass como
chave e o TS narrowingo funciona. Se em vez disso você usasse string
(subscribe('channel.live', ...)), perde exhaustive checking — os eventos
viram strings mágicas.Quando faz sentido:
Quando NÃO faz sentido:
Um erro comum é ler "Estágio 2 → Estágio 3" e concluir que Bus é sempre "mais poderoso" e Emitter é só "temporário". Não é bem assim — os dois operam em planos diferentes e cada um vence em cenários distintos. Esta seção compara com código concreto.
Com Emitter, pra escutar você precisa da referência ao produtor:
Com Bus, você conhece só a classe do evento — nem precisa saber que existe
um ChannelMonitor no sistema:
Isso permite módulos "plug-in" que se inscrevem sem tocar em main.ts nem
receber injeção do produtor.
Cenário: tanto Monitor quanto Recorder publicam TelemetryEvent (genérico
"algo relevante aconteceu"). Consumidor único que registra todos.
Com Emitters (impossível compartilhar tipo entre produtores diferentes):
Com Bus (o mesmo handler recebe dos dois):
Cenário: logger central que registra todo evento do daemon.
Com Emitters, precisa amarrar em cada produtor manualmente:
Com Bus, uma linha catch-all:
Mesma lógica pra métricas Prometheus, audit trail persistente, etc.
Emitter emite tudo pra todos os listeners inscritos — filtragem é responsabilidade do handler:
Bus roteia por identidade do tipo — cada handler só recebe o que assinou:
Não é lista curta pra dar "peso equivalente" — são vantagens reais que fazem Emitter continuar sendo a escolha certa em muitos cenários mesmo depois que Bus estiver disponível no projeto.
Cada produtor tem seu Emitter. Lendo ChannelMonitor, você vê o Emitter
como campo/prop — sabe imediatamente que ele emite eventos. Com Bus, produtor
chama bus.publish(...) como um método qualquer, e você precisa procurar
essas chamadas pra mapear quem publica o quê.
Emitter<MonitorEvent> é tipado no compilador — TS sabe o shape exato,
tagged union narrowing funciona automático dentro de handlers, refactor
seguro.
Bus roteia por event.constructor como chave em Map. Isso força:
handler as (e: any) => void pra guardar handlers
heterogêneos no mesmo MapFunciona, mas TS "trabalha menos" — mais chance de bug passar despercebido.
Cmd+Click no .on() de um Emitter te leva à assinatura, e "Find All
References" mostra quem chama. Com Bus:
bus.subscribe(SomeEvent, ...) pode estar em N arquivosNão é impossível, é só mais custoso.
Cada Emitter é escopado à sua classe dona. Vazamentos, listeners esquecidos, comportamento inesperado — tudo isolado. Bus é objeto compartilhado: um handler mal comportado que adiciona listeners em loop afeta todo mundo.
| Cenário | Escolha certa |
|---|---|
| 1-to-N (um produtor conhecido, consumidor específico) | Emitter |
| N-to-1 (poucos produtores conhecidos, consumidor único) | Emitter |
| N-to-N com produtores/consumidores anônimos entre si | Bus |
| Cross-cutting (logger, métrica, audit escutando tudo) | Bus |
| Plugin system / auto-registro dinâmico | Bus |
Consumidor auto-inscrito sem tocar em main.ts | Bus |
| Fluxo linear e conhecido (Monitor → Recorder e mais nada) | Emitter |
Emitter e Bus não são "simples" e "complexo" da mesma escala — são ferramentas com propósito diferente. Bus não deprecia Emitter; ele resolve um problema (N-to-N desacoplado) que Emitter deliberadamente não resolve pra manter as vantagens acima. Num daemon maduro, os dois podem coexistir: Emitters locais pra fluxos diretos + Bus pra cross-cutting.
A superfície pública do consumidor não muda entre os estágios.
monitor.on(handler)monitor.on(handler) (delega ao Emitter interno)bus.subscribe(ChannelLiveEvent, handler) — só aqui o call site
precisa mudar, e é fácil localizarOu seja: você não é preso pela decisão inicial. Escolher listener local hoje
não custa refactor grande quando migrar amanhã — só troca o miolo do
emit()/on() da classe, os call sites do on() continuam iguais.
| Cenário atual | Estágio |
|---|---|
| 1 produtor de eventos, ≤ 3 consumidores conhecidos | 1 (local) |
| 2-3 produtores, mecânica se repetindo | 2 (Emitter genérico) |
| Rede multi-produtor/multi-consumer, consumidores anônimos | 3 (EventBus) |
| Cross-process, garantia de entrega, retry | fora do escopo in-memory — usar MQ |
Traduzindo pro daemon hoje: Estágio 3. Existe um EventBus central em
@shared/events, uma única instância criada em main.ts e injetada nos
produtores (hoje só o Monitor; futuro Recorder herda o mesmo bus). Consumers
assinam por classe de evento: bus.subscribe(ChannelLiveEvent, handler).
Por que promovemos do Estágio 2 pro 3:
if (event.type === 'live') ... no início. Com Bus por classe, cada
handler recebe só o tipo que assinou — filtragem sai do produtor pro
dispatcher.monitor.on(...) e todos recebem todos os eventos. Com Bus,
subscribe por classe é 1 linha por par (handler, evento).RecordingStartedEvent, RecordingFinishedEvent etc, o Bus já centraliza
— evita "N produtores conhecidos entre si" que Emitter empurra.Trade-offs aceitos:
Cmd+Click do consumer pro producer — rastreabilidade agora é grep
por bus.subscribe(ClasseX, ...)Base Event compartilhada: todo evento implementa Event com
occurredAt: Date. Habilita handlers cross-cutting (logger, audit, métrica)
que só precisam do timestamp comum, sem depender do shape específico.
EventBus, EventChannelLiveEvent, ChannelOfflineEvent (classes implementando Event)ChannelMonitorProps.bus: EventBus, publica com bus.publish(new ClasseEvento(...))new EventBus() + bus.subscribe(ClasseEvento, handler) por classeexport class Emitter<T> {
private listeners: Array<(e: T) => void | Promise<void>> = []
on(fn: (e: T) => void | Promise<void>) {
this.listeners.push(fn)
}
async emit(e: T) {
for (const fn of this.listeners) {
try { await fn(e) }
catch (err) { console.error('listener failed', err) }
}
}
}export class ChannelMonitor {
private events = new Emitter<MonitorEvent>()
on(listener: MonitorListener) { this.events.on(listener) }
// ...
private async transition(event: MonitorEvent) {
await this.events.emit(event)
}
}export class ChannelLiveEvent {
readonly occurredAt = new Date()
constructor(readonly username: string) {}
}export class EventBus {
private handlers = new Map<Function, Array<(e: any) => Promise<void>>>()
subscribe<E>(EventClass: new (...args: any[]) => E, handler: (e: E) => Promise<void>) {
const list = this.handlers.get(EventClass) ?? []
list.push(handler as any)
this.handlers.set(EventClass, list)
}
async publish<E extends object>(event: E) {
const list = this.handlers.get(event.constructor) ?? []
for (const handler of list) {
try { await handler(event) }
catch (err) { console.error('handler failed', err) }
}
}
}await bus.publish(new ChannelLiveEvent('lexi'))bus.subscribe(ChannelLiveEvent, async (e) => {
await recorder.start(e.username)
})monitor.on(handler) // preciso ter 'monitor' em escopobus.subscribe(ChannelLiveEvent, handler) // nem sei quem publica isso// Cada produtor tem seu Emitter<TipoEspecífico> — tipos incompatíveis
monitor.on(handleTelemetry) // recebe MonitorEvent
recorder.on(handleTelemetry) // recebe RecorderEvent
// Consumidor tem que amarrar nos dois E lidar com tipos diferentesbus.subscribe(TelemetryEvent, handleTelemetry)
// Monitor faz: await bus.publish(new TelemetryEvent(...))
// Recorder faz: await bus.publish(new TelemetryEvent(...))
// Handler recebe dos dois, sem saber quem publicoumonitor.on(e => logger.log('monitor', e))
recorder.on(e => logger.log('recorder', e))
store.on(e => logger.log('store', e))
// Adicionou produtor X? Tem que lembrar de wire aqui — bug latente.bus.subscribe(Event, e => logger.log(e)) // Event = classe raiz
// Novo produtor publicando no bus → logger vê automaticamente.monitor.on(e => {
if (e.type === 'live') handleLive(e)
else handleOffline(e) // handler filtra na entrada
})bus.subscribe(ChannelLiveEvent, handleLive) // só live chega aqui
bus.subscribe(ChannelOfflineEvent, handleOffline) // só offline aqui