Close long-lived SSE streams before HTTP shutdown so graceful shutdown is not held until the deadline. Also wait for background runners before closing the store to make shutdown ordering explicit.
144 lines
3.5 KiB
Go
144 lines
3.5 KiB
Go
package v1
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"sync"
|
|
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
// SSEEventType represents the type of change event.
|
|
type SSEEventType string
|
|
|
|
const (
|
|
SSEEventMemoCreated SSEEventType = "memo.created"
|
|
SSEEventMemoUpdated SSEEventType = "memo.updated"
|
|
SSEEventMemoDeleted SSEEventType = "memo.deleted"
|
|
SSEEventMemoCommentCreated SSEEventType = "memo.comment.created"
|
|
SSEEventReactionUpserted SSEEventType = "reaction.upserted"
|
|
SSEEventReactionDeleted SSEEventType = "reaction.deleted"
|
|
)
|
|
|
|
// SSEEvent represents a change event sent to SSE clients.
|
|
type SSEEvent struct {
|
|
Type SSEEventType `json:"type"`
|
|
// Name is the affected resource name (e.g., "memos/xxxx").
|
|
// For reaction events, this is the memo resource name that the reaction belongs to.
|
|
Name string `json:"name"`
|
|
// Parent is the parent memo resource name when the affected resource is a comment.
|
|
Parent string `json:"parent,omitempty"`
|
|
// Visibility and CreatorID are used only for server-side delivery filtering.
|
|
Visibility store.Visibility `json:"-"`
|
|
CreatorID int32 `json:"-"`
|
|
}
|
|
|
|
// JSON returns the JSON representation of the event.
|
|
// Returns nil if marshaling fails (error is logged).
|
|
func (e *SSEEvent) JSON() []byte {
|
|
data, err := json.Marshal(e)
|
|
if err != nil {
|
|
slog.Error("failed to marshal SSE event", "err", err, "event", e)
|
|
return nil
|
|
}
|
|
return data
|
|
}
|
|
|
|
// SSEClient represents a single SSE connection.
|
|
type SSEClient struct {
|
|
events chan []byte
|
|
userID int32
|
|
role store.Role
|
|
}
|
|
|
|
// SSEHub manages SSE client connections and broadcasts events.
|
|
// It is safe for concurrent use.
|
|
type SSEHub struct {
|
|
mu sync.RWMutex
|
|
clients map[*SSEClient]struct{}
|
|
closed bool
|
|
}
|
|
|
|
// NewSSEHub creates a new SSE hub.
|
|
func NewSSEHub() *SSEHub {
|
|
return &SSEHub{
|
|
clients: make(map[*SSEClient]struct{}),
|
|
}
|
|
}
|
|
|
|
// Subscribe registers a new client and returns it.
|
|
// The caller must call Unsubscribe when done.
|
|
func (h *SSEHub) Subscribe(userID int32, role store.Role) *SSEClient {
|
|
c := &SSEClient{
|
|
// Buffer a few events so a slow client doesn't block broadcasting.
|
|
events: make(chan []byte, 32),
|
|
userID: userID,
|
|
role: role,
|
|
}
|
|
h.mu.Lock()
|
|
if h.closed {
|
|
close(c.events)
|
|
} else {
|
|
h.clients[c] = struct{}{}
|
|
}
|
|
h.mu.Unlock()
|
|
return c
|
|
}
|
|
|
|
// Unsubscribe removes a client and closes its channel.
|
|
func (h *SSEHub) Unsubscribe(c *SSEClient) {
|
|
h.mu.Lock()
|
|
if _, ok := h.clients[c]; ok {
|
|
delete(h.clients, c)
|
|
close(c.events)
|
|
}
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
// Close disconnects all subscribed SSE clients.
|
|
func (h *SSEHub) Close() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.closed {
|
|
return
|
|
}
|
|
h.closed = true
|
|
for c := range h.clients {
|
|
delete(h.clients, c)
|
|
close(c.events)
|
|
}
|
|
}
|
|
|
|
// Broadcast sends an event to all connected clients.
|
|
// Slow clients that have a full buffer will have the event dropped
|
|
// to avoid blocking the broadcaster.
|
|
func (h *SSEHub) Broadcast(event *SSEEvent) {
|
|
data := event.JSON()
|
|
if len(data) == 0 {
|
|
return
|
|
}
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
for c := range h.clients {
|
|
if !c.canReceive(event) {
|
|
continue
|
|
}
|
|
select {
|
|
case c.events <- data:
|
|
default:
|
|
// Drop event for slow client to avoid blocking.
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *SSEClient) canReceive(event *SSEEvent) bool {
|
|
switch event.Visibility {
|
|
case store.Private:
|
|
return c.userID == event.CreatorID || c.role == store.RoleAdmin
|
|
case store.Public, store.Protected, "":
|
|
return true
|
|
default:
|
|
slog.Warn("SSE canReceive: unknown visibility type, denying event", "visibility", event.Visibility)
|
|
return false
|
|
}
|
|
}
|