mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user