// 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() cb.hub.Publish(&transport.Envelope{ Type: transport.TypeTrackSnapshot, Payload: transport.TrackSnapshot{ GeneratedMs: snap.GeneratedMs, Version: snap.Version, Tracks: tracksToWire(snap.Tracks), }, }) 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() sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{ GeneratedMs: snap.GeneratedMs, Version: snap.Version, Tracks: tracksToWire(out), }) 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 } sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{ Tracks: []transport.TrackWire{trackToWire(t)}, }) 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 } sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)}) 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 } sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)}) 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}) }