Files
Faza Iman Imron f373776d4c feat(plugins): add retained event pub/sub over IPC (#303)
* feat(ipc): add retained event subscriptions

* feat(plugins): expose namespaced event publishing

* feat(app): wire plugin events to IPC

* docs(plugins): document local event pubsub

* fix(ipc): clear request deadline for subscriptions

* fix(luaplugin): install event publisher before plugins load

* fix(luaplugin): clear retained events after publishers stop

* fix(ipc): keep event sequence gap-free when retention is rejected

* fix(luaplugin): break table cycles and cap depth in Lua value conversion

* docs(plugins): document conversion limits and subscription stream rules

* refactor(ipc): report a missing server with a sentinel error

* fix(luaplugin): fold event namespace into one topic segment

* docs(site): mention plugin event publishing and subscriptions

* fix(luaplugin): keep event namespaces unique across plugins

* fix(luaplugin): release event namespace by installed filename

* refactor(ipc): render the not-running message in the command layer

* docs(plugins): clarify namespace prefixes and JSON conversion limits
2026-08-18 17:19:28 +02:00

222 lines
5.4 KiB
Go

package ipc
import (
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"time"
)
const (
maxTopicsPerSubscription = 32
maxRetainedTopics = 256
maxEventPayloadSize = 64 << 10
subscriberBufferSize = 32
)
var (
ErrInvalidTopic = errors.New("invalid event topic")
ErrTooManyTopics = errors.New("too many subscription topics")
ErrPayloadTooLarge = errors.New("event payload exceeds 64 KiB")
ErrTooManyRetained = errors.New("too many retained event topics")
)
// Event is one message delivered over an IPC subscription. Sequence numbers
// are process-local and monotonically increasing. Retained marks events replayed
// to a newly connected subscriber.
type Event struct {
Event string `json:"event"`
Sequence uint64 `json:"seq"`
Time int64 `json:"time"`
Retained bool `json:"retained,omitempty"`
Data json.RawMessage `json:"data"`
}
// Subscription is an in-memory event stream owned by a Broker.
type Subscription struct {
broker *Broker
id uint64
events <-chan Event
once sync.Once
}
func (s *Subscription) Events() <-chan Event { return s.events }
// Close unregisters the subscription. It is safe to call more than once.
func (s *Subscription) Close() {
if s == nil || s.broker == nil {
return
}
s.once.Do(func() { s.broker.unsubscribe(s.id) })
}
type subscriber struct {
topics map[string]struct{}
events chan Event
}
// Broker distributes process-local events and optionally retains the latest
// event per topic. Publish never waits for a subscriber: a slow subscriber is
// disconnected rather than being allowed to block Cliamp or a Lua callback.
type Broker struct {
mu sync.Mutex
nextEvent uint64
nextSub uint64
retained map[string]Event
subscribers map[uint64]*subscriber
closed bool
}
func NewBroker() *Broker {
return &Broker{
retained: make(map[string]Event),
subscribers: make(map[uint64]*subscriber),
}
}
// Publish sends data to current subscribers and, when retain is true, stores
// the latest value in memory for replay to future subscribers.
func (b *Broker) Publish(topic string, data json.RawMessage, retain bool) error {
if !validTopic(topic) {
return ErrInvalidTopic
}
if len(data) > maxEventPayloadSize {
return ErrPayloadTooLarge
}
if !json.Valid(data) {
return errors.New("event payload is not valid JSON")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return errors.New("event broker is closed")
}
// Reject before consuming a sequence number so numbering stays gap-free.
if retain {
if _, exists := b.retained[topic]; !exists && len(b.retained) >= maxRetainedTopics {
return ErrTooManyRetained
}
}
b.nextEvent++
event := Event{
Event: topic,
Sequence: b.nextEvent,
Time: time.Now().Unix(),
Data: append(json.RawMessage(nil), data...),
}
if retain {
b.retained[topic] = event
}
for id, sub := range b.subscribers {
if _, ok := sub.topics[topic]; !ok {
continue
}
select {
case sub.events <- event:
default:
delete(b.subscribers, id)
close(sub.events)
}
}
return nil
}
// Subscribe registers an exact-topic subscription and queues retained values
// before any subsequently published events. Topic ordering makes retained
// replay deterministic.
func (b *Broker) Subscribe(topics []string) (*Subscription, error) {
if len(topics) == 0 {
return nil, errors.New("at least one topic is required")
}
if len(topics) > maxTopicsPerSubscription {
return nil, ErrTooManyTopics
}
set := make(map[string]struct{}, len(topics))
for _, topic := range topics {
if !validTopic(topic) {
return nil, ErrInvalidTopic
}
set[topic] = struct{}{}
}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return nil, errors.New("event broker is closed")
}
b.nextSub++
id := b.nextSub
capacity := subscriberBufferSize
if len(set) > capacity {
capacity = len(set)
}
ch := make(chan Event, capacity)
sub := &subscriber{topics: set, events: ch}
b.subscribers[id] = sub
sorted := make([]string, 0, len(set))
for topic := range set {
sorted = append(sorted, topic)
}
sort.Strings(sorted)
for _, topic := range sorted {
if event, ok := b.retained[topic]; ok {
event.Retained = true
ch <- event
}
}
return &Subscription{broker: b, id: id, events: ch}, nil
}
// ClearPrefix removes retained values under a plugin namespace. Live
// subscribers remain connected and will receive future publications.
func (b *Broker) ClearPrefix(prefix string) {
b.mu.Lock()
for topic := range b.retained {
if strings.HasPrefix(topic, prefix) {
delete(b.retained, topic)
}
}
b.mu.Unlock()
}
// Close disconnects all subscribers and rejects future publications and
// subscriptions. It is safe to call more than once.
func (b *Broker) Close() {
b.mu.Lock()
if !b.closed {
b.closed = true
for id, sub := range b.subscribers {
delete(b.subscribers, id)
close(sub.events)
}
clear(b.retained)
}
b.mu.Unlock()
}
func (b *Broker) unsubscribe(id uint64) {
b.mu.Lock()
if sub, ok := b.subscribers[id]; ok {
delete(b.subscribers, id)
close(sub.events)
}
b.mu.Unlock()
}
func validTopic(topic string) bool {
if topic == "" || len(topic) > 256 || strings.HasPrefix(topic, ".") || strings.HasSuffix(topic, ".") || strings.Contains(topic, "..") {
return false
}
for _, r := range topic {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
continue
}
return false
}
return true
}