Files
kandev/apps/backend/internal/plugins/webapp/events.go
T
2026-09-05 18:44:43 +00:00

764 lines
22 KiB
Go

package webapp
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
DefaultEventCapacity = 1000
DefaultEventReplayWindow = 5 * time.Minute
DefaultEventHistoryTTL = DefaultEventReplayWindow
DefaultEventHeartbeatInterval = 15 * time.Second
DefaultEventSubscriberQueueSize = 32
DefaultEventMaxStreamsPerUser = 20
DefaultEventMaxStreamsPerInstance = 2
DefaultEventMaxPayloadBytes = 256 << 10
DefaultEventStreamLifetime = 15 * time.Minute
RuntimeResyncRequired = "runtime.resync_required"
ResyncReasonHistoryGap = "history_gap"
ResyncReasonGenerationMismatch = "generation_mismatch"
ResyncReasonInvalidCursor = "invalid_cursor"
ResyncReasonCursorAhead = "cursor_ahead"
)
var (
ErrEventHubClosed = errors.New("webapp: event hub is closed")
ErrInvalidEvent = errors.New("webapp: invalid event")
ErrEventPayloadTooLarge = errors.New("webapp: event payload exceeds limit")
ErrInvalidSubscription = errors.New("webapp: invalid event subscription")
ErrTooManyEventStreams = errors.New("webapp: too many event streams")
ErrSlowSubscriber = errors.New("webapp: slow event subscriber")
)
// EventScope identifies the resources represented by an event. InstanceID is
// always populated by EventHub; the remaining fields are optional and are
// retained as public scope metadata for the web application.
type EventScope struct {
InstanceID string `json:"instance_id"`
WorkspaceID string `json:"workspace_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
RepositoryID string `json:"repository_id,omitempty"`
}
// EventInput is the public, bounded input accepted by Publish. Data is
// marshaled before the event is retained, so callers cannot mutate history by
// changing a map or slice after Publish returns.
type EventInput struct {
Type string
Scope EventScope
Data any
}
// Event is the immutable public event delivered to subscribers and encoded in
// an SSE data field. ID is generation:sequence and is stable for replay.
type Event struct {
ID string `json:"id,omitempty"`
Generation string `json:"generation"`
Sequence uint64 `json:"sequence,omitempty"`
Type string `json:"type"`
Scope EventScope `json:"scope"`
Data json.RawMessage `json:"data"`
published time.Time `json:"-"`
}
// EventFilter is evaluated for every live and replayed event. A parent
// runtime can close over current grants and scope checks so authorization is
// re-evaluated without making the hub depend on the Service layer.
type EventFilter func(Event) bool
// ResyncInfo is the payload of RuntimeResyncRequired. The empty SSE id sent
// with this event resets the browser's Last-Event-ID cursor.
type ResyncInfo struct {
Reason string `json:"reason"`
Generation string `json:"generation"`
Reset bool `json:"reset"`
}
// EventSubscriptionRequest binds a stream to one authorized user and plugin
// instance. The request is intended to be created by the capability runtime,
// not from browser-supplied scope fields.
type EventSubscriptionRequest struct {
InstanceID string
UserID string
LastEventID string
Filter EventFilter
}
// EventHubConfig provides bounded operational limits. Zero values use the
// production defaults. Now is injectable for deterministic replay expiry
// tests.
type EventHubConfig struct {
Generation string
MaxEvents int
ReplayWindow time.Duration
SubscriberQueueSize int
HeartbeatInterval time.Duration
StreamLifetime time.Duration
MaxStreamsPerUser int
MaxStreamsPerUserInstance int
MaxPayloadBytes int
Now func() time.Time
}
// EventHub is an in-memory, per-process event transport. Event history is
// partitioned by plugin instance, while the generation identifies this
// process lifetime for reconnect recovery.
type EventHub struct {
mu sync.Mutex
instances map[string]*eventInstance
userStreams map[string]int
userInstanceStreams map[eventStreamKey]int
closed bool
generation string
config EventHubConfig
now func() time.Time
}
type eventInstance struct {
lastSequence uint64
events []Event
subscribers map[*EventSubscription]struct{}
}
type eventStreamKey struct {
userID string
instanceID string
}
// EventSubscription owns one bounded subscriber queue. Closing the
// subscription is idempotent and safe to race with Publish, Close, and
// request cancellation.
type EventSubscription struct {
hub *EventHub
instanceID string
userID string
filter EventFilter
events chan Event
done chan struct{}
stateMu sync.RWMutex
closed bool
err error
}
// NewEventHub creates a hub with production defaults. At most one config is
// used; the variadic form permits both NewEventHub() and concise test setup.
func NewEventHub(configs ...EventHubConfig) *EventHub {
config := EventHubConfig{}
if len(configs) > 0 {
config = configs[0]
}
applyEventHubDefaults(&config)
generation := strings.TrimSpace(config.Generation)
if !validGeneration(generation) {
generation = newEventGeneration()
}
now := config.Now
if now == nil {
now = time.Now
}
return &EventHub{
instances: make(map[string]*eventInstance),
userStreams: make(map[string]int),
userInstanceStreams: make(map[eventStreamKey]int),
generation: generation,
config: config,
now: now,
}
}
func applyEventHubDefaults(config *EventHubConfig) {
if config.MaxEvents <= 0 {
config.MaxEvents = DefaultEventCapacity
}
if config.ReplayWindow <= 0 {
config.ReplayWindow = DefaultEventReplayWindow
}
if config.SubscriberQueueSize <= 0 {
config.SubscriberQueueSize = DefaultEventSubscriberQueueSize
}
if config.HeartbeatInterval <= 0 {
config.HeartbeatInterval = DefaultEventHeartbeatInterval
}
if config.StreamLifetime <= 0 {
config.StreamLifetime = DefaultEventStreamLifetime
}
if config.MaxStreamsPerUser <= 0 {
config.MaxStreamsPerUser = DefaultEventMaxStreamsPerUser
}
if config.MaxStreamsPerUserInstance <= 0 {
config.MaxStreamsPerUserInstance = DefaultEventMaxStreamsPerInstance
}
if config.MaxPayloadBytes <= 0 {
config.MaxPayloadBytes = DefaultEventMaxPayloadBytes
}
}
func newEventGeneration() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err == nil {
return hex.EncodeToString(buf)
}
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
func validGeneration(generation string) bool {
return generation != "" && !strings.ContainsAny(generation, "\r\n\t ")
}
// Generation returns the process generation used by this hub.
func (h *EventHub) Generation() string {
if h == nil {
return ""
}
return h.generation
}
// Publish assigns a per-instance monotonic sequence and makes the event
// available to future reconnects. Subscriber delivery is always non-blocking;
// a full subscriber queue is disconnected instead.
func (h *EventHub) Publish(instanceID string, input EventInput) (Event, error) {
if h == nil {
return Event{}, ErrEventHubClosed
}
instanceID = strings.TrimSpace(instanceID)
if err := validatePublishedEvent(instanceID, input); err != nil {
return Event{}, err
}
data, err := marshalEventData(input.Data, h.config.MaxPayloadBytes)
if err != nil {
return Event{}, err
}
scope := input.Scope
if scope.InstanceID == "" {
scope.InstanceID = instanceID
}
if scope.InstanceID != instanceID {
return Event{}, fmt.Errorf("%w: event instance does not match publish instance", ErrInvalidEvent)
}
now := h.now().UTC()
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return Event{}, ErrEventHubClosed
}
instance := h.instanceLocked(instanceID)
instance.purgeExpired(now, h.config.ReplayWindow)
if instance.lastSequence == ^uint64(0) {
h.mu.Unlock()
return Event{}, fmt.Errorf("%w: event sequence exhausted", ErrInvalidEvent)
}
instance.lastSequence++
event := Event{
ID: formatEventID(h.generation, instance.lastSequence),
Generation: h.generation,
Sequence: instance.lastSequence,
Type: strings.TrimSpace(input.Type),
Scope: scope,
Data: cloneRawMessage(data),
published: now,
}
instance.events = append(instance.events, event)
if len(instance.events) > h.config.MaxEvents {
instance.events = instance.events[len(instance.events)-h.config.MaxEvents:]
}
subscribers := make([]*EventSubscription, 0, len(instance.subscribers))
for subscriber := range instance.subscribers {
subscribers = append(subscribers, subscriber)
}
for _, subscriber := range subscribers {
candidate := cloneEvent(event)
if subscriber.filter != nil && !subscriber.filter(candidate) {
continue
}
h.enqueueLocked(subscriber, candidate)
}
h.mu.Unlock()
event.Data = cloneRawMessage(event.Data)
return event, nil
}
func validatePublishedEvent(instanceID string, input EventInput) error {
if instanceID == "" || strings.ContainsAny(instanceID, "\r\n") {
return fmt.Errorf("%w: instance id is required", ErrInvalidEvent)
}
typ := strings.TrimSpace(input.Type)
if typ == "" || strings.ContainsAny(typ, "\r\n") {
return fmt.Errorf("%w: event type is required", ErrInvalidEvent)
}
return nil
}
func marshalEventData(data any, maxBytes int) (json.RawMessage, error) {
encoded, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("%w: marshal data: %v", ErrInvalidEvent, err)
}
if len(encoded) > maxBytes {
return nil, fmt.Errorf("%w: got %d bytes, limit is %d", ErrEventPayloadTooLarge, len(encoded), maxBytes)
}
return json.RawMessage(encoded), nil
}
func (h *EventHub) instanceLocked(instanceID string) *eventInstance {
instance := h.instances[instanceID]
if instance == nil {
instance = &eventInstance{subscribers: make(map[*EventSubscription]struct{})}
h.instances[instanceID] = instance
}
return instance
}
func (i *eventInstance) purgeExpired(now time.Time, window time.Duration) {
cutoff := now.Add(-window)
index := 0
for index < len(i.events) && i.events[index].publishedAt().Before(cutoff) {
index++
}
if index > 0 {
i.events = i.events[index:]
}
}
func (e Event) publishedAt() time.Time {
return e.published
}
func (h *EventHub) enqueueLocked(subscriber *EventSubscription, event Event) {
select {
case subscriber.events <- event:
default:
h.removeSubscriptionLocked(subscriber, ErrSlowSubscriber)
}
}
// Subscribe creates a bounded stream and queues any complete replay range
// before returning. A cancellable context owns the subscription and releases
// it without requiring a timeout or a separate cleanup call.
func (h *EventHub) Subscribe(ctx context.Context, request EventSubscriptionRequest) (*EventSubscription, error) {
if h == nil {
return nil, ErrEventHubClosed
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
request.InstanceID = strings.TrimSpace(request.InstanceID)
request.UserID = strings.TrimSpace(request.UserID)
if request.InstanceID == "" || request.UserID == "" || strings.ContainsAny(request.InstanceID+request.UserID, "\r\n") {
return nil, ErrInvalidSubscription
}
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return nil, ErrEventHubClosed
}
key := eventStreamKey{userID: request.UserID, instanceID: request.InstanceID}
if h.userStreams[request.UserID] >= h.config.MaxStreamsPerUser || h.userInstanceStreams[key] >= h.config.MaxStreamsPerUserInstance {
h.mu.Unlock()
return nil, ErrTooManyEventStreams
}
instance := h.instanceLocked(request.InstanceID)
instance.purgeExpired(h.now().UTC(), h.config.ReplayWindow)
replay, resyncReason := h.replayLocked(instance, request)
queueSize := h.config.SubscriberQueueSize
initialEvents := len(replay)
if resyncReason != "" {
initialEvents++
}
if queueSize <= initialEvents {
queueSize = initialEvents + 1
}
subscriber := &EventSubscription{
hub: h,
instanceID: request.InstanceID,
userID: request.UserID,
events: make(chan Event, queueSize),
done: make(chan struct{}),
filter: request.Filter,
}
instance.subscribers[subscriber] = struct{}{}
h.userStreams[request.UserID]++
h.userInstanceStreams[key]++
if resyncReason != "" {
h.enqueueLocked(subscriber, newResyncEvent(request.InstanceID, h.generation, resyncReason))
} else {
for _, event := range replay {
h.enqueueLocked(subscriber, event)
}
}
h.mu.Unlock()
watchSubscriptionContext(ctx, subscriber)
return subscriber, nil
}
func (h *EventHub) replayLocked(instance *eventInstance, request EventSubscriptionRequest) ([]Event, string) {
lastID := strings.TrimSpace(request.LastEventID)
if lastID == "" {
return nil, ""
}
generation, sequence, ok := ParseEventID(lastID)
if !ok {
return nil, ResyncReasonInvalidCursor
}
if generation != h.generation {
return nil, ResyncReasonGenerationMismatch
}
if sequence > instance.lastSequence {
return nil, ResyncReasonCursorAhead
}
if len(instance.events) == 0 {
if sequence < instance.lastSequence {
return nil, ResyncReasonHistoryGap
}
return nil, ""
}
oldest := instance.events[0].Sequence
if sequence < oldest-1 {
return nil, ResyncReasonHistoryGap
}
replay := make([]Event, 0, len(instance.events))
for _, event := range instance.events {
if event.Sequence <= sequence {
continue
}
candidate := cloneEvent(event)
if request.Filter == nil || request.Filter(candidate) {
replay = append(replay, candidate)
}
}
return replay, ""
}
func newResyncEvent(instanceID, generation, reason string) Event {
data, _ := json.Marshal(ResyncInfo{Reason: reason, Generation: generation, Reset: true})
return Event{
Generation: generation,
Type: RuntimeResyncRequired,
Scope: EventScope{InstanceID: instanceID},
Data: data,
}
}
func watchSubscriptionContext(ctx context.Context, subscriber *EventSubscription) {
if ctx.Done() == nil {
return
}
go func() {
select {
case <-ctx.Done():
_ = subscriber.closeWithError(ctx.Err())
case <-subscriber.done:
}
}()
}
// Events returns the replay and live event queue. It is closed when the
// subscription is canceled, explicitly closed, the hub closes, or a slow
// consumer is disconnected.
func (s *EventSubscription) Events() <-chan Event {
if s == nil {
return nil
}
return s.events
}
// Done closes with the event queue and is useful to join cancellation cleanup
// in callers that consume Events directly.
func (s *EventSubscription) Done() <-chan struct{} {
if s == nil {
return nil
}
return s.done
}
// Err reports why the subscription was closed, if it was closed abnormally.
func (s *EventSubscription) Err() error {
if s == nil {
return nil
}
s.stateMu.RLock()
defer s.stateMu.RUnlock()
return s.err
}
// IsClosed reports whether the subscription no longer accepts events.
func (s *EventSubscription) IsClosed() bool {
if s == nil {
return true
}
s.stateMu.RLock()
defer s.stateMu.RUnlock()
return s.closed
}
// Close releases the subscription. It is safe to call more than once.
func (s *EventSubscription) Close() error {
return s.closeWithError(nil)
}
// Unsubscribe is an alias for Close for callers that use subscription-style
// terminology.
func (s *EventSubscription) Unsubscribe() error {
return s.Close()
}
func (s *EventSubscription) closeWithError(err error) error {
if s == nil || s.hub == nil {
return nil
}
s.hub.mu.Lock()
s.hub.removeSubscriptionLocked(s, err)
s.hub.mu.Unlock()
return nil
}
func (h *EventHub) removeSubscriptionLocked(subscriber *EventSubscription, err error) {
instance := h.instances[subscriber.instanceID]
if instance != nil {
if _, active := instance.subscribers[subscriber]; active {
delete(instance.subscribers, subscriber)
key := eventStreamKey{userID: subscriber.userID, instanceID: subscriber.instanceID}
decrementCount(h.userStreams, subscriber.userID)
decrementCount(h.userInstanceStreams, key)
}
}
subscriber.closeLocked(err)
}
func decrementCount[K comparable](counts map[K]int, key K) {
if counts[key] <= 1 {
delete(counts, key)
return
}
counts[key]--
}
func (s *EventSubscription) closeLocked(err error) {
s.stateMu.Lock()
defer s.stateMu.Unlock()
if s.closed {
return
}
s.closed = true
s.err = err
close(s.events)
close(s.done)
}
// Close terminates the hub and all of its subscriptions. It does not wait on
// consumers or publishers because no hub operation performs a blocking send.
func (h *EventHub) Close() {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if h.closed {
return
}
h.closed = true
for _, instance := range h.instances {
for subscriber := range instance.subscribers {
subscriber.closeLocked(ErrEventHubClosed)
}
}
h.instances = make(map[string]*eventInstance)
h.userStreams = make(map[string]int)
h.userInstanceStreams = make(map[eventStreamKey]int)
}
// Handler binds an authorized instance/user/filter tuple to a standard
// net/http handler. The request's Last-Event-ID header is preferred over the
// bound request value, and no query-token handling is provided here.
func (h *EventHub) Handler(request EventSubscriptionRequest) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.Serve(w, r, request)
})
}
// Serve writes one SSE stream for an authorized subscription request.
func (h *EventHub) Serve(w http.ResponseWriter, r *http.Request, request EventSubscriptionRequest) {
if h == nil {
writeEventStreamError(w, http.StatusServiceUnavailable, ErrEventHubClosed)
return
}
if !validEventStreamRequest(w, r) {
return
}
flusher, ok := eventStreamFlusher(w)
if !ok {
return
}
request = eventStreamRequest(r, request)
subscriber, err := h.Subscribe(r.Context(), request)
if err != nil {
writeEventStreamError(w, eventStreamErrorStatus(err), err)
return
}
defer func() { _ = subscriber.Close() }()
h.stream(w, r, flusher, subscriber)
}
func validEventStreamRequest(w http.ResponseWriter, r *http.Request) bool {
if r != nil && r.Method == http.MethodGet {
return true
}
if w != nil {
w.Header().Set("Allow", http.MethodGet)
}
writeEventStreamError(w, http.StatusMethodNotAllowed, ErrInvalidSubscription)
return false
}
func eventStreamFlusher(w http.ResponseWriter) (http.Flusher, bool) {
flusher, ok := w.(http.Flusher)
if !ok {
writeEventStreamError(w, http.StatusInternalServerError, errors.New("webapp: SSE requires response flushing"))
return nil, false
}
return flusher, true
}
func eventStreamRequest(r *http.Request, request EventSubscriptionRequest) EventSubscriptionRequest {
if headerID := strings.TrimSpace(r.Header.Get("Last-Event-ID")); headerID != "" {
request.LastEventID = headerID
}
return request
}
func (h *EventHub) stream(w http.ResponseWriter, r *http.Request, flusher http.Flusher, subscriber *EventSubscription) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ticker := time.NewTicker(h.config.HeartbeatInterval)
defer ticker.Stop()
var lifetime <-chan time.Time
var timer *time.Timer
if h.config.StreamLifetime > 0 {
timer = time.NewTimer(h.config.StreamLifetime)
defer timer.Stop()
lifetime = timer.C
}
for {
select {
case event, open := <-subscriber.Events():
if !open {
return
}
if err := writeSSE(w, event); err != nil {
return
}
flusher.Flush()
case <-ticker.C:
if _, err := io.WriteString(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
case <-r.Context().Done():
return
case <-lifetime:
return
}
}
}
func writeSSE(w io.Writer, event Event) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
var stream strings.Builder
if event.ID == "" && event.Type == RuntimeResyncRequired {
stream.WriteString("id:\n")
} else if event.ID != "" {
stream.WriteString("id: ")
stream.WriteString(event.ID)
stream.WriteByte('\n')
}
stream.WriteString("event: ")
stream.WriteString(event.Type)
stream.WriteString("\ndata: ")
stream.Write(data)
stream.WriteString("\n\n")
_, err = io.WriteString(w, stream.String())
return err
}
func eventStreamErrorStatus(err error) int {
switch {
case errors.Is(err, ErrEventHubClosed):
return http.StatusServiceUnavailable
case errors.Is(err, ErrTooManyEventStreams):
return http.StatusTooManyRequests
case errors.Is(err, ErrInvalidSubscription):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
func writeEventStreamError(w http.ResponseWriter, status int, err error) {
if w == nil {
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.Error(w, err.Error(), status)
}
// ParseEventID decodes the generation and sequence assigned by Publish.
func ParseEventID(id string) (generation string, sequence uint64, ok bool) {
id = strings.TrimSpace(id)
separator := strings.LastIndexByte(id, ':')
if separator <= 0 || separator == len(id)-1 {
return "", 0, false
}
generation = id[:separator]
sequence, err := strconv.ParseUint(id[separator+1:], 10, 64)
if err != nil || sequence == 0 || !validGeneration(generation) {
return "", 0, false
}
return generation, sequence, true
}
func formatEventID(generation string, sequence uint64) string {
return generation + ":" + strconv.FormatUint(sequence, 10)
}
func cloneEvent(event Event) Event {
event.Data = cloneRawMessage(event.Data)
return event
}
func cloneRawMessage(data json.RawMessage) json.RawMessage {
if data == nil {
return nil
}
return append(json.RawMessage(nil), data...)
}