// Package api — in-memory pub/sub bus for SSE broadcast. // // TODO(v0.2): if a second consumer (e.g. from another VPS) is added, // replace this in-memory bus with NATS or Redis pub/sub. For now // (1 local client) the overhead of an external broker is unwarranted. package api import ( "sync" ) // Event is a generic event payload (JSON-serialisable). type Event = any // Bus is a simple in-memory pub/sub hub. // Topics are arbitrary strings (e.g. "status", "logs/agent-id"). type Bus struct { mu sync.RWMutex subs map[string][]chan Event } // NewBus creates an initialised Bus. func NewBus() *Bus { return &Bus{subs: make(map[string][]chan Event)} } // Subscribe returns a channel that receives events published to topic. // The channel is buffered (32) to avoid blocking the publisher. func (b *Bus) Subscribe(topic string) <-chan Event { ch := make(chan Event, 32) b.mu.Lock() b.subs[topic] = append(b.subs[topic], ch) b.mu.Unlock() return ch } // Unsubscribe removes ch from topic and closes it. func (b *Bus) Unsubscribe(topic string, ch <-chan Event) { b.mu.Lock() defer b.mu.Unlock() list := b.subs[topic] for i, c := range list { if c == ch { close(c) b.subs[topic] = append(list[:i], list[i+1:]...) return } } } // Publish sends ev to all subscribers of topic. // Non-blocking: if a subscriber channel is full, the event is dropped for that subscriber. func (b *Bus) Publish(topic string, ev Event) { b.mu.RLock() list := b.subs[topic] b.mu.RUnlock() for _, ch := range list { select { case ch <- ev: default: // drop for this slow subscriber } } }