claw-code/.guardrails/examples/go/admin-ui/websocket-events.go

375 lines
9.9 KiB
Go

// WebSocket Events - Real-time Game Event Streaming
// Production-ready WebSocket event broadcasting for game backends
//
// Last Updated: 2026-03-14
// Go Version: 1.22+
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
)
// WSEventStreamConfig defines WebSocket configuration
type WSEventStreamConfig struct {
WriteBufferSize int `json:"write_buffer_size"`
ReadBufferSize int `json:"read_buffer_size"`
MaxMessageSize int64 `json:"max_message_size"`
PingInterval time.Duration `json:"ping_interval"`
WriteTimeout time.Duration `json:"write_timeout"`
AllowedOrigins []string `json:"allowed_origins"`
}
// GameEventWS represents WebSocket game event
type GameEventWS struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
Sequence uint64 `json:"sequence"` // Eventual consistency
Timestamp time.Time `json:"timestamp"` // Server authority
Source string `json:"source"` // "server" authority
PlayerID string `json:"player_id"`
Region string `json:"region"`
}
// WSClient represents connected WebSocket client
type WSClient struct {
ID string
Conn *websocket.Conn
EventChan chan GameEventWS
Context context.Context
Cancel context.CancelFunc
LastPing time.Time
Sequence uint64 // Client last received sequence
}
// WSEventStream implements WebSocket event streaming
type WSEventStream struct {
config WSEventStreamConfig
clients map[string]*WSClient
broadcast chan GameEventWS
mu sync.RWMutex
upgrader *websocket.Upgrader
sequence uint64
}
// NewWSEventStream creates WebSocket event stream
func NewWSEventStream(config WSEventStreamConfig) (*WSEventStream, error) {
s := &WSEventStream{
config: config,
clients: make(map[string]*WSClient),
broadcast: make(chan GameEventWS, 100),
sequence: 0,
upgrader: &websocket.Upgrader{
WriteBufferSize: config.WriteBufferSize,
ReadBufferSize: config.ReadBufferSize,
MaxMessageSize: config.MaxMessageSize,
CheckOrigin: func(r *http.Request) bool {
for _, origin := range config.AllowedOrigins {
if r.Header.Get("Origin") == origin {
return true
}
}
return false
},
},
}
return s, nil
}
// ConnectClient handles WebSocket connection
func (s *WSEventStream) ConnectClient(w http.ResponseWriter, r *http.Request) (*WSClient, error) {
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
return nil, fmt.Errorf("client_id required")
}
// Upgrade to WebSocket
conn, err := s.upgrader.Upgrader(w, r, nil)
if err != nil {
log.Printf("[WS] Upgrade failed: %v", err)
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
client := &WSClient{
ID: clientID,
Conn: conn,
EventChan: make(chan GameEventWS, 100),
Context: ctx,
Cancel: cancel,
LastPing: time.Now(),
Sequence: 0,
}
s.mu.Lock()
s.clients[clientID] = client
s.mu.Unlock()
log.Printf("[WS] Client %s connected", clientID)
// Start client goroutines
go s.handleClientMessages(client)
go s.handleClientEvents(client)
return client, nil
}
// handleClientMessages processes incoming WebSocket messages
func (s *WSEventStream) handleClientMessages(client *WSClient) {
defer client.Cancel()
for {
select {
case <-client.Context.Done():
return
default:
// Read message with timeout
client.Conn.SetReadDeadline(time.Now().Add(s.config.ReadBufferSize))
_, message, err := client.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosed) {
log.Printf("[WS] Read error for %s: %v", client.ID, err)
}
return
}
// Process message (client-to-server events)
var event GameEventWS
if err := json.Unmarshal(message, &event); err != nil {
log.Printf("[WS] Unmarshal error: %v", err)
continue
}
// Server-authority validation
event.Source = "server"
event.Timestamp = time.Now()
s.sequence++
event.Sequence = s.sequence
log.Printf("[WS] Client %s sent event: %s", client.ID, event.Type)
}
}
}
// handleClientEvents streams events to client
func (s *WSEventStream) handleClientEvents(client *WSClient) {
defer client.Cancel()
defer client.Conn.Close()
pingTicker := time.NewTicker(s.config.PingInterval)
defer pingTicker.Stop()
for {
select {
case <-client.Context.Done():
return
case event := range client.EventChan:
// Eventual consistency: sequence validation
data, _ := json.Marshal(event)
client.Conn.SetWriteDeadline(time.Now().Add(s.config.WriteTimeout))
if err := client.Conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("[WS] Write error for %s: %v", client.ID, err)
return
}
client.Sequence = event.Sequence
log.Printf("[WS] Event %d sent to %s", event.Sequence, client.ID)
case <-pingTicker.C:
client.Conn.SetWriteDeadline(time.Now().Add(s.config.WriteTimeout))
if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
log.Printf("[WS] Ping error for %s: %v", client.ID, err)
return
}
client.LastPing = time.Now()
log.Printf("[WS] Ping sent to %s", client.ID)
}
}
}
// BroadcastEvent broadcasts event to all clients
func (s *WSEventStream) BroadcastEvent(event GameEventWS) {
s.mu.Lock()
defer s.mu.Unlock()
s.sequence++
event.Sequence = s.sequence
event.Timestamp = time.Now()
event.Source = "server"
s.broadcast <- event
for clientID, client := range s.clients {
select {
case client.EventChan <- event:
log.Printf("[WS] Event %d broadcast to %s", event.Sequence, clientID)
default:
log.Printf("[WS] Client %s buffer full, dropping event %d", clientID, event.Sequence)
}
}
}
// RemoveClient disconnects client
func (s *WSEventStream) RemoveClient(clientID string) {
s.mu.Lock()
defer s.mu.Unlock()
if client, ok := s.clients[clientID]; ok {
client.Cancel()
client.Conn.Close()
delete(s.clients, clientID)
log.Printf("[WS] Client %s disconnected", clientID)
}
}
// GetClientCount returns connected client count
func (s *WSEventStream) GetClientCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.clients)
}
// GetClientSequence returns client's last received sequence
func (s *WSEventStream) GetClientSequence(clientID string) (uint64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
client, ok := s.clients[clientID]
if !ok {
return 0, fmt.Errorf("client not found")
}
return client.Sequence, nil
}
// ReSyncClient resyncs client from sequence (reconnect recovery)
func (s *WSEventStream) ReSyncClient(clientID string, fromSequence uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
client, ok := s.clients[clientID]
if !ok {
return fmt.Errorf("client not found")
}
// Replay missed events
for seq := fromSequence + 1; seq <= s.sequence; seq++ {
// Replay logic - fetch from event store
log.Printf("[WS] Replaying event %d for %s", seq, clientID)
}
return nil
}
// WSEventStreamHandler renders WebSocket event stream UI
func (s *WSEventStream) WSEventStreamHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Event Stream</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
<style>
.event-log { border: 1px solid #ccc; padding: 1rem; max-height: 400px; overflow-y: auto; }
.event-item { border-bottom: 1px solid #eee; padding: 0.5rem; }
</style>
</head>
<body>
<main hx-boost="true" role="main">
<h1>WebSocket Event Stream</h1>
<div hx-get="/ws/clients" hx-trigger="every 5s">
<!-- Client count polling -->
</div>
<div
class="event-log"
hx-ext="ws"
hx-ws="connect:/ws?client_id=admin"
aria-live="assertive"
role="log"
aria-label="Event stream">
<!-- Events streamed via WebSocket -->
<span>Connected to event stream</span>
</div>
<div hx-get="/ws/sequence" hx-trigger="load">
<!-- Current sequence -->
</div>
</main>
</body>
</html>
`)
}
// ClientsHandler renders connected clients
func (s *WSEventStream) ClientsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
count := s.GetClientCount()
fmt.Fprintf(w, `
<div role="status" aria-live="polite">
Connected Clients: %d
</div>
`, count)
}
// SequenceHandler renders current sequence
func (s *WSEventStream) SequenceHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
s.mu.RLock()
defer s.mu.RUnlock()
fmt.Fprintf(w, `
<div role="status" aria-live="polite">
Current Sequence: %d
</div>
`, s.sequence)
}
// registerWSRoutes registers WebSocket event stream routes
func (s *WSEventStream) registerWSRoutes(r *mux.Router) {
ws := r.PathPrefix("/ws").Subrouter()
ws.HandleFunc("/connect", func(w http.ResponseWriter, r *http.Request) {
s.ConnectClient(w, r)
}).Methods("GET")
ws.HandleFunc("/clients", s.ClientsHandler).Methods("GET")
ws.HandleFunc("/sequence", s.SequenceHandler).Methods("GET")
ws.HandleFunc("/", s.WSEventStreamHandler).Methods("GET")
}
// initDemoEvents initializes demo event streaming
func (s *WSEventStream) initDemoEvents() {
// Broadcast demo events
s.BroadcastEvent(GameEventWS{
Type: "GAME_START",
Payload: map[string]string{"message": "Game started"},
Source: "server",
})
s.BroadcastEvent(GameEventWS{
Type: "PLAYER_LOGIN",
Payload: map[string]string{"player_id": "player-1"},
Source: "server",
})
s.BroadcastEvent(GameEventWS{
Type: "ECONOMY_UPDATE",
Payload: map[string]string{"resource": "gold", "change": "+100"},
Source: "server",
})
}