Files

305 lines
9.7 KiB
Go

// WebSocket catalog handlers + event broadcaster.
//
// The HTTP side lives in catalog_handlers.go. This file owns the WS side:
// - the catalogBroadcaster subscribes to catalog events and pushes
// track_snapshot / car_snapshot / catalog_summary frames to all
// connected clients via the realtime hub.
// - handleTrackWSMessage / handleCarWSMessage dispatch individual
// track_* / car_* requests from a single client.
package main
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/x0gp/server/internal/catalog"
"github.com/x0gp/server/internal/realtime"
"github.com/x0gp/server/internal/transport"
)
// wsCtx returns a context for catalog mutations triggered from a
// WebSocket message. We deliberately use Background() because the
// per-message pgxpool query is already bounded by pgx's own
// statement_timeout / connection settings.
func wsCtx() context.Context {
return context.Background()
}
// ---------------------------------------------------------------------------
// Broadcaster
// ---------------------------------------------------------------------------
type catalogBroadcaster struct {
svc *catalog.Service
hub *Hub
log *slog.Logger
stop chan struct{}
done chan struct{}
}
func newCatalogBroadcaster(svc *catalog.Service, hub *Hub, log *slog.Logger) *catalogBroadcaster {
return &catalogBroadcaster{
svc: svc,
hub: hub,
log: log,
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
func (cb *catalogBroadcaster) Run() {
defer close(cb.done)
events, cancel := cb.svc.Subscribe()
defer cancel()
for {
select {
case <-cb.stop:
return
case ev, ok := <-events:
if !ok {
return
}
cb.broadcast(ev)
}
}
}
func (cb *catalogBroadcaster) Stop() {
select {
case <-cb.stop:
return
default:
close(cb.stop)
}
<-cb.done
}
func (cb *catalogBroadcaster) broadcast(ev catalog.Event) {
switch ev.Kind {
case catalog.EventSnapshot:
// Initial subscription push; clients request via track_list/car_list.
return
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
snap := cb.svc.Snapshot()
activeTracks := getActiveTracks(context.Background(), cb.svc)
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeTrackSnapshot,
Payload: transport.TrackSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Tracks: tracksToWire(snap.Tracks, activeTracks),
},
})
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeCatalogSummary,
Payload: summaryToWire(cb.svc.Stats()),
})
case catalog.EventCarUpsert, catalog.EventCarDelete:
snap := cb.svc.Snapshot()
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeCarSnapshot,
Payload: transport.CarSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Cars: carsToWire(snap.Cars),
},
})
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeCatalogSummary,
Payload: summaryToWire(cb.svc.Stats()),
})
}
}
// ---------------------------------------------------------------------------
// Per-message WS handlers
// ---------------------------------------------------------------------------
// handleTrackWSMessage is called from the readPump dispatch on track_* messages.
func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) {
payloadBytes, _ := json.Marshal(env.Payload)
switch env.Type {
case transport.TypeTrackList:
var req transport.TrackListRequest
_ = json.Unmarshal(payloadBytes, &req)
var out []catalog.TrackMeta
switch strings.ToLower(req.Scope) {
case "system":
out = svc.ListTracksByVisibility(catalog.VisibilitySystem)
case "public":
out = svc.ListTracksByVisibility(catalog.VisibilityPublic)
case "private":
out = svc.ListTracksByVisibility(catalog.VisibilityPrivate)
default:
out = svc.ListTracks()
}
if req.Tag != "" {
out = filterTracksByTag(out, req.Tag)
}
snap := svc.Snapshot()
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Tracks: tracksToWire(out, activeTracks),
})
case transport.TypeTrackGet:
var req transport.TrackGetRequest
_ = json.Unmarshal(payloadBytes, &req)
if req.ID == "" {
sendErrorWS(c, "track_get", "missing id")
return
}
t, ok := svc.GetTrack(req.ID)
if !ok {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"})
return
}
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
Tracks: []transport.TrackWire{trackToWire(t, activeTracks)},
})
case transport.TypeTrackCreate:
var req transport.TrackCreateRequest
_ = json.Unmarshal(payloadBytes, &req)
in := trackFromWire(req.Track)
created, err := svc.CreateTrack(wsCtx(), catalog.CreateTrackOptions{
ID: in.ID, Name: in.Name, Description: in.Description,
AuthorID: in.AuthorID, Visibility: in.Visibility, Scale: in.Scale,
LengthM: in.LengthM, WidthM: in.WidthM, LaneWidthM: in.LaneWidthM,
Surface: in.Surface, Tags: in.Tags, Centerline: in.Centerline,
})
if err != nil {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, Error: err.Error()})
return
}
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
case transport.TypeTrackUpdate:
var req transport.TrackUpdateRequest
_ = json.Unmarshal(payloadBytes, &req)
patch := trackPatchFromWire(req.Patch)
updated, err := svc.UpdateTrack(wsCtx(), req.ID, patch)
if err != nil {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)})
case transport.TypeTrackDelete:
var req transport.TrackDeleteRequest
_ = json.Unmarshal(payloadBytes, &req)
if err := svc.DeleteTrack(wsCtx(), req.ID); err != nil {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: req.ID})
}
}
func handleCarWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) {
payloadBytes, _ := json.Marshal(env.Payload)
switch env.Type {
case transport.TypeCarList:
var req transport.CarListRequest
_ = json.Unmarshal(payloadBytes, &req)
var out []catalog.CarMeta
if req.OwnerID != "" {
out = svc.ListCarsByOwner(req.OwnerID)
} else {
switch strings.ToLower(req.Scope) {
case "system":
out = svc.ListCarsByVisibility(catalog.VisibilitySystem)
case "public":
out = svc.ListCarsByVisibility(catalog.VisibilityPublic)
case "private":
out = svc.ListCarsByVisibility(catalog.VisibilityPrivate)
default:
out = svc.ListCars()
}
}
snap := svc.Snapshot()
sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Cars: carsToWire(out),
})
case transport.TypeCarGet:
var req transport.CarGetRequest
_ = json.Unmarshal(payloadBytes, &req)
if req.ID == "" {
sendErrorWS(c, "car_get", "missing id")
return
}
car, ok := svc.GetCar(req.ID)
if !ok {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: "not_found"})
return
}
sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{
Cars: []transport.CarWire{carToWire(car)},
})
case transport.TypeCarCreate:
var req transport.CarCreateRequest
_ = json.Unmarshal(payloadBytes, &req)
in := carFromWire(req.Car)
created, err := svc.CreateCar(wsCtx(), catalog.CreateCarOptions{
ID: in.ID, Name: in.Name, OwnerID: in.OwnerID, Visibility: in.Visibility,
Scale: in.Scale, LengthMm: in.LengthMm, WidthMm: in.WidthMm,
HeightMm: in.HeightMm, WeightG: in.WeightG, Chassis: in.Chassis,
Motor: in.Motor, Battery: in.Battery, Drive: in.Drive,
TopSpeedMs: in.TopSpeedMs, ColorHex: in.ColorHex, AvatarURL: in.AvatarURL,
})
if err != nil {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, Error: err.Error()})
return
}
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: created.ID, Car: carToWire(created)})
case transport.TypeCarUpdate:
var req transport.CarUpdateRequest
_ = json.Unmarshal(payloadBytes, &req)
patch := carPatchFromWire(req.Patch)
updated, err := svc.UpdateCar(wsCtx(), req.ID, patch)
if err != nil {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: updated.ID, Car: carToWire(updated)})
case transport.TypeCarDelete:
var req transport.CarDeleteRequest
_ = json.Unmarshal(payloadBytes, &req)
if err := svc.DeleteCar(wsCtx(), req.ID); err != nil {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: req.ID})
}
}
// ---------------------------------------------------------------------------
// Send helpers (non-blocking on full send buffer)
// ---------------------------------------------------------------------------
func sendWS(c *realtime.Client, t transport.MessageType, payload any) {
data, err := transport.Encode(&transport.Envelope{
Type: t,
TSMs: time.Now().UnixMilli(),
Payload: payload,
})
if err != nil {
return
}
select {
case c.Send <- data:
default:
// Drop if the client is too slow.
}
}
func sendErrorWS(c *realtime.Client, code, msg string) {
sendWS(c, transport.TypeError, transport.ErrorMsg{Code: code, Message: msg})
}