258 lines
5.4 KiB
Go
258 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sync/atomic"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// ---- WebSocket hub ----
|
|
|
|
// Hub serialises all queue mutations through a single goroutine (run).
|
|
// This eliminates data races without requiring a queue-level mutex.
|
|
type Hub struct {
|
|
clients map[*Client]bool
|
|
registerC chan *Client
|
|
unregisterC chan *Client
|
|
broadcastC chan []byte // pre-serialised JSON pushed to all clients
|
|
dispatchC chan dispatch // inbound client messages
|
|
timerC chan string // entry IDs for 60s-timer-triggered removal
|
|
stopC chan struct{}
|
|
activeCount atomic.Int32 // len(q.Entries); read by Registry.List without locking
|
|
}
|
|
|
|
type dispatch struct {
|
|
c *Client // nil for timer-triggered removals
|
|
payload []byte
|
|
}
|
|
|
|
func newHub() *Hub {
|
|
return &Hub{
|
|
clients: make(map[*Client]bool),
|
|
registerC: make(chan *Client, 16),
|
|
unregisterC: make(chan *Client, 16),
|
|
broadcastC: make(chan []byte, 256),
|
|
dispatchC: make(chan dispatch, 256),
|
|
timerC: make(chan string, 64),
|
|
stopC: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// shutdown signals the hub goroutine to exit.
|
|
func (h *Hub) shutdown() {
|
|
close(h.stopC)
|
|
}
|
|
|
|
// run is the single goroutine that owns all hub and queue state.
|
|
func (h *Hub) run(q *QueueState, reg *Registry, queueID string) {
|
|
for {
|
|
select {
|
|
case <-h.stopC:
|
|
return
|
|
|
|
case c := <-h.registerC:
|
|
h.clients[c] = true
|
|
|
|
case c := <-h.unregisterC:
|
|
if _, ok := h.clients[c]; ok {
|
|
delete(h.clients, c)
|
|
close(c.send)
|
|
}
|
|
|
|
case msg := <-h.broadcastC:
|
|
for c := range h.clients {
|
|
select {
|
|
case c.send <- msg:
|
|
default:
|
|
// Slow client: drop and disconnect.
|
|
delete(h.clients, c)
|
|
close(c.send)
|
|
}
|
|
}
|
|
|
|
case d := <-h.dispatchC:
|
|
h.handleMessage(d.c, d.payload, q, reg, queueID)
|
|
|
|
case entryID := <-h.timerC:
|
|
if q.timerRemove(entryID) {
|
|
h.pushState(q)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// pushState broadcasts the full current queue state to all clients.
|
|
func (h *Hub) pushState(q *QueueState) {
|
|
h.activeCount.Store(int32(len(q.Entries)))
|
|
b := q.stateJSON()
|
|
select {
|
|
case h.broadcastC <- b:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// sendTo sends a JSON-serialisable value to one client.
|
|
func (h *Hub) sendTo(c *Client, v interface{}) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return
|
|
}
|
|
select {
|
|
case c.send <- b:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// ---- WebSocket client ----
|
|
|
|
type Client struct {
|
|
hub *Hub
|
|
conn *websocket.Conn
|
|
send chan []byte
|
|
token string // set after init/join; used for all subsequent actions
|
|
}
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
func (h *Hub) wsHandler(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
c := &Client{
|
|
hub: h,
|
|
conn: conn,
|
|
send: make(chan []byte, 64),
|
|
}
|
|
h.registerC <- c
|
|
go c.writePump()
|
|
c.readPump()
|
|
}
|
|
|
|
func (c *Client) readPump() {
|
|
defer func() {
|
|
c.hub.unregisterC <- c
|
|
c.conn.Close()
|
|
}()
|
|
c.conn.SetReadLimit(4096)
|
|
for {
|
|
_, raw, err := c.conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
c.hub.dispatchC <- dispatch{c: c, payload: raw}
|
|
}
|
|
}
|
|
|
|
func (c *Client) writePump() {
|
|
defer c.conn.Close()
|
|
for msg := range c.send {
|
|
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Message handling ----
|
|
|
|
type inMsg struct {
|
|
Type string `json:"type"`
|
|
Token string `json:"token,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
EntryID string `json:"entryId,omitempty"`
|
|
}
|
|
|
|
func (h *Hub) handleMessage(c *Client, raw []byte, q *QueueState, reg *Registry, queueID string) {
|
|
var msg inMsg
|
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
|
return
|
|
}
|
|
|
|
switch msg.Type {
|
|
|
|
case "init":
|
|
// Client sends their stored token (empty string if none).
|
|
c.token = msg.Token
|
|
var entryID interface{} // nil → JSON null
|
|
if msg.Token != "" {
|
|
if e := q.findByToken(msg.Token); e != nil {
|
|
entryID = e.ID
|
|
} else {
|
|
c.token = "" // stale token
|
|
}
|
|
}
|
|
h.sendTo(c, map[string]interface{}{"type": "hello", "yourEntryId": entryID})
|
|
// Send current state so client renders queue immediately.
|
|
h.sendTo(c, json.RawMessage(q.stateJSON()))
|
|
|
|
case "join":
|
|
// Anti-spam: reject if this client already has an active entry.
|
|
if c.token != "" {
|
|
if e := q.findByToken(c.token); e != nil {
|
|
h.sendTo(c, map[string]interface{}{"type": "hello", "yourEntryId": e.ID})
|
|
return
|
|
}
|
|
}
|
|
e, token := q.join(msg.Name)
|
|
c.token = token
|
|
h.sendTo(c, map[string]interface{}{
|
|
"type": "joined",
|
|
"yourToken": token,
|
|
"yourEntryId": e.ID,
|
|
})
|
|
h.pushState(q)
|
|
reg.Touch(queueID)
|
|
|
|
case "done":
|
|
if c.token == "" {
|
|
return
|
|
}
|
|
if q.selfRemove(c.token) {
|
|
c.token = ""
|
|
h.pushState(q)
|
|
}
|
|
|
|
case "mark_done":
|
|
if c.token == "" || msg.EntryID == "" {
|
|
return
|
|
}
|
|
actor := q.findByToken(c.token)
|
|
if actor == nil {
|
|
return
|
|
}
|
|
if q.markDone(actor.Number, msg.EntryID, h.timerC) {
|
|
h.pushState(q)
|
|
}
|
|
|
|
case "vote_remove":
|
|
if c.token == "" || msg.EntryID == "" {
|
|
return
|
|
}
|
|
actor := q.findByToken(c.token)
|
|
if actor == nil {
|
|
return
|
|
}
|
|
switch q.voteRemove(actor.Number, msg.EntryID, h.timerC) {
|
|
case "ok":
|
|
h.pushState(q)
|
|
case "already_voting":
|
|
onNum := q.findVoteTarget(actor.Number)
|
|
h.sendTo(c, map[string]interface{}{
|
|
"type": "error", "reason": "already_voting", "onNumber": onNum,
|
|
})
|
|
}
|
|
|
|
case "restore":
|
|
if c.token == "" {
|
|
return
|
|
}
|
|
if q.restore(c.token) {
|
|
h.pushState(q)
|
|
}
|
|
}
|
|
}
|