mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-02 09:03:45 +00:00
289 lines
9.1 KiB
Go
289 lines
9.1 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
// Package hub provides the central fan-out broker between the MQTT ingest
|
|
// goroutines and connected WebSocket clients.
|
|
//
|
|
// Design:
|
|
// - A single Hub.Run() goroutine owns the client map; no mutexes needed.
|
|
// - Ingest goroutines call hub.Broadcast() from any goroutine.
|
|
// - Each WebSocket connection gets a *Client with a buffered send channel.
|
|
// - If a client's send buffer is full it receives a lagged notification and
|
|
// its buffer is drained so it doesn't stall the broadcast loop.
|
|
//
|
|
// Subscription filtering (by IATA, payload type, channel hash, etc.) is
|
|
// enforced here before events are placed on a client's send channel.
|
|
package hub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"slices"
|
|
)
|
|
|
|
// EventType identifies the kind of server-push event. These match the
|
|
// discriminator values in the WebSocket protocol ("packetObservation", etc.).
|
|
type EventType string
|
|
|
|
const (
|
|
EventPacketObservation EventType = "packetObservation"
|
|
EventObserverStatus EventType = "observerStatus"
|
|
EventNodeUpdate EventType = "nodeUpdate"
|
|
EventChannelMessage EventType = "channelMessage"
|
|
)
|
|
|
|
// Event is a single fan-out unit. Payload is pre-serialised JSON so the
|
|
// broadcast loop never touches encoding — it's done once by the ingest path.
|
|
//
|
|
// PayloadResolved is an optional second serialization carrying additional
|
|
// fields for clients that opted into them via configure (currently just
|
|
// resolvedPath on packetObservation events). Left nil for event types that
|
|
// don't have an opt-in variant; the hub falls back to Payload in that case.
|
|
type Event struct {
|
|
Type EventType
|
|
Payload json.RawMessage
|
|
PayloadResolved json.RawMessage
|
|
|
|
// Routing metadata used by the hub to match subscriptions.
|
|
// Populated by the ingest layer before calling Broadcast.
|
|
IATA string
|
|
PayloadType uint8
|
|
ChannelHash string // hex string, non-empty only for channelMessage events
|
|
}
|
|
|
|
// Scope mirrors the client-side subscribe message. All fields are optional:
|
|
// nil/empty means "no filter on this dimension" (match everything).
|
|
// An empty non-nil slice means "match nothing on this dimension".
|
|
type Scope struct {
|
|
IATAs []string
|
|
PayloadTypes []uint8
|
|
ChannelHashes []string
|
|
Events []EventType
|
|
}
|
|
|
|
type LaggedNotification struct {
|
|
DroppedCount int
|
|
}
|
|
|
|
// Client represents a connected WebSocket consumer.
|
|
type Client struct {
|
|
Send chan Event
|
|
laggedCH chan LaggedNotification
|
|
subscriptions map[string]Scope // OR semantics: event matches if it matches any scope entry
|
|
|
|
// ResolvePath is a connection-wide opt-in (not per-subscription), set via
|
|
// SetResolvePath ("configure" WS messages). Freely toggleable at any
|
|
// point during the connection's lifetime. Only ever read/written inside
|
|
// Run(), so it needs no locking despite Client being shared with the WS
|
|
// goroutines.
|
|
ResolvePath bool
|
|
}
|
|
|
|
// matches returns true if the event satisfies at least one of the client's
|
|
// active subscriptions.
|
|
func (c *Client) matches(e Event) bool {
|
|
if len(c.subscriptions) == 0 {
|
|
return false
|
|
}
|
|
for _, s := range c.subscriptions {
|
|
if scopeMatches(s, e) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// LaggedCH returns the channel on which lagged notifications are delivered.
|
|
// The WS write pump selects on this alongside Send.
|
|
func (c *Client) LaggedCH() <-chan LaggedNotification {
|
|
return c.laggedCH
|
|
}
|
|
|
|
func scopeMatches(s Scope, e Event) bool {
|
|
if len(s.Events) > 0 && !slices.Contains(s.Events, e.Type) {
|
|
return false
|
|
}
|
|
if len(s.IATAs) > 0 && !slices.Contains(s.IATAs, e.IATA) {
|
|
return false
|
|
}
|
|
if len(s.PayloadTypes) > 0 && !slices.Contains(s.PayloadTypes, e.PayloadType) {
|
|
return false
|
|
}
|
|
if e.Type == EventChannelMessage && len(s.ChannelHashes) > 0 && !slices.Contains(s.ChannelHashes, e.ChannelHash) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Hub is the central event broker.
|
|
type Hub struct {
|
|
subscribe chan subscribeMsg
|
|
unsubscribe chan unsubscribeMsg
|
|
remove chan *Client
|
|
broadcast chan Event
|
|
}
|
|
|
|
// subscribeMsg carries a client registration, a scope subscription, or a
|
|
// configure (resolvePath toggle) request — all three go through this single
|
|
// channel, not separate ones, specifically so that Go's same-channel FIFO
|
|
// guarantee orders them relative to NewClient's registration message. A
|
|
// separate "configure" channel raced against registration: select() has no
|
|
// cross-channel ordering guarantee, so a configure sent immediately after
|
|
// NewClient could be (and empirically was, ~50% of the time) processed by
|
|
// Run() before the registration message, silently no-oping since the client
|
|
// wasn't in the clients map yet.
|
|
type subscribeMsg struct {
|
|
client *Client
|
|
scope Scope
|
|
subscriptionID string
|
|
|
|
isConfigure bool
|
|
resolvePath bool
|
|
}
|
|
|
|
type unsubscribeMsg struct {
|
|
client *Client
|
|
subscriptionID string
|
|
}
|
|
|
|
// configureMsg carries a connection-wide setting change, decoupled from the
|
|
// subscribe/unsubscribe scope mechanics so it can be toggled independently
|
|
// and repeatedly over the life of a connection.
|
|
type configureMsg struct {
|
|
client *Client
|
|
resolvePath bool
|
|
}
|
|
|
|
// New creates a Hub. Call Run() in a goroutine before using it.
|
|
func New() *Hub {
|
|
return &Hub{
|
|
subscribe: make(chan subscribeMsg, 64),
|
|
unsubscribe: make(chan unsubscribeMsg, 64),
|
|
remove: make(chan *Client, 64),
|
|
broadcast: make(chan Event, 512),
|
|
}
|
|
}
|
|
|
|
// NewClient creates a Client and registers it with the hub.
|
|
// The caller is responsible for calling Remove when the connection closes.
|
|
func (h *Hub) NewClient() *Client {
|
|
c := &Client{
|
|
Send: make(chan Event, 256),
|
|
laggedCH: make(chan LaggedNotification, 8),
|
|
subscriptions: make(map[string]Scope),
|
|
}
|
|
// We don't add it to the map here; we send it through the channel so
|
|
// Run() is the only goroutine that touches the client map.
|
|
h.subscribe <- subscribeMsg{client: c}
|
|
return c
|
|
}
|
|
|
|
// AddScope appends a subscription scope to a client. Called by the WS handler
|
|
// when it receives a "subscribe" message from the client.
|
|
func (h *Hub) AddScope(c *Client, id string, s Scope) {
|
|
h.subscribe <- subscribeMsg{client: c, scope: s, subscriptionID: id}
|
|
}
|
|
|
|
// RemoveScope removes a single subscription by ID. Called by the WS handler
|
|
// when it receives an "unsubscribe" message from the client. Silently ignored
|
|
// if the ID is not found.
|
|
func (h *Hub) RemoveScope(c *Client, id string) {
|
|
h.unsubscribe <- unsubscribeMsg{client: c, subscriptionID: id}
|
|
}
|
|
|
|
// SetResolvePath toggles a client's opt-in to the resolvedPath variant of
|
|
// packetObservation events. Unlike scopes, this is a single connection-wide
|
|
// flag (not additive/OR'd) and can be flipped on or off at any point during
|
|
// the connection's lifetime — takes effect on the next broadcast after the
|
|
// hub processes it.
|
|
func (h *Hub) SetResolvePath(c *Client, enabled bool) {
|
|
h.subscribe <- subscribeMsg{client: c, isConfigure: true, resolvePath: enabled}
|
|
}
|
|
|
|
// Remove deregisters a client and closes its Send channel.
|
|
// Safe to call from any goroutine (e.g. the WS handler's defer).
|
|
func (h *Hub) Remove(c *Client) {
|
|
h.remove <- c
|
|
}
|
|
|
|
// Broadcast enqueues an event for fan-out. Safe to call from any goroutine.
|
|
func (h *Hub) Broadcast(e Event) {
|
|
select {
|
|
case h.broadcast <- e:
|
|
default:
|
|
log.Println("hub: broadcast channel full, dropping event")
|
|
}
|
|
}
|
|
|
|
// Run is the hub's single-goroutine event loop. Call it in a dedicated
|
|
// goroutine: go hub.Run().
|
|
//
|
|
// It processes registrations, removals, and broadcasts sequentially so the
|
|
// clients map needs no locking.
|
|
func (h *Hub) Run() {
|
|
// clients maps a *Client to the set of subscription IDs it holds.
|
|
// We use a map[*Client]struct{} for O(1) presence checks and O(n)
|
|
// broadcast — fine at the scale Beacon targets.
|
|
clients := make(map[*Client]struct{})
|
|
|
|
for {
|
|
select {
|
|
|
|
case msg := <-h.subscribe:
|
|
switch {
|
|
case msg.subscriptionID == "" && !msg.isConfigure:
|
|
// Registration with no scope yet (NewClient path).
|
|
clients[msg.client] = struct{}{}
|
|
case msg.isConfigure:
|
|
// SetResolvePath path — client must already be registered.
|
|
if _, ok := clients[msg.client]; ok {
|
|
msg.client.ResolvePath = msg.resolvePath
|
|
}
|
|
default:
|
|
// AddScope path — client must already be registered.
|
|
if _, ok := clients[msg.client]; ok {
|
|
msg.client.subscriptions[msg.subscriptionID] = msg.scope
|
|
}
|
|
}
|
|
|
|
case msg := <-h.unsubscribe:
|
|
if _, ok := clients[msg.client]; ok {
|
|
delete(msg.client.subscriptions, msg.subscriptionID)
|
|
}
|
|
|
|
case c := <-h.remove:
|
|
if _, ok := clients[c]; ok {
|
|
delete(clients, c)
|
|
close(c.Send)
|
|
close(c.laggedCH)
|
|
}
|
|
|
|
case evt := <-h.broadcast:
|
|
for c := range clients {
|
|
if !c.matches(evt) {
|
|
continue
|
|
}
|
|
outEvt := evt
|
|
if c.ResolvePath && evt.PayloadResolved != nil {
|
|
outEvt.Payload = evt.PayloadResolved
|
|
}
|
|
select {
|
|
case c.Send <- outEvt:
|
|
default:
|
|
dropped := 1
|
|
select {
|
|
case <-c.Send:
|
|
default:
|
|
}
|
|
select {
|
|
case c.laggedCH <- LaggedNotification{DroppedCount: dropped}:
|
|
default:
|
|
// laggedCh itself full; write pump will catch up on next drain
|
|
}
|
|
log.Printf("hub: client send buffer full, dropped event type=%s", evt.Type)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|