Files
noitu/server/internal/wsapi/server.go
T
tiennm99 aea9ea8cc0 feat(wsapi): offer a rematch when an online game ends
A finished room now stays alive to ask both players whether they want another,
and restarts with a fresh opening word once both do. Only a room with two
connected humans offers one: a bot has nothing to negotiate, so a bot room
closes the moment its game ends rather than leaking a goroutine and an engine
per finished game.

turn_seq no longer restarts at one. A rematch reuses the same connections, so a
submission still in flight from the previous game could otherwise match a turn
in the new one and be applied to it.

The end-of-game decision sits after the whole select, so every way a game can
end reaches it. Opening the offer from the message arm alone meant the turn
clock — the most common natural ending — closed the room with nothing to accept.

A resume is refused into a finished game, including one waiting on a rematch
answer, so the room now retires the connection being replaced only once it has
agreed to the swap. Retiring it up front ended the game the client was trying
to rejoin, which a duplicated tab was enough to trigger.

attach releases the room it is leaving. Nothing else told that room the
connection had gone, so a session asking for several rooms stranded all but the
last, each parked in select holding a goroutine and a room code for the life of
the process.

Also: RequestRematch is rate limited, because it is the only client message
that fans out to both players and an unbounded one lets a burst fill the
opponent's outbox until their session is closed for falling behind. And a
resume announces itself to the opponent, who was otherwise left watching a
disconnect banner for someone already playing again.
2026-09-05 14:17:47 +07:00

178 lines
5.4 KiB
Go

package wsapi
import (
"context"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/coder/websocket"
)
// Config is everything the transport layer needs to run.
type Config struct {
// TurnLimit is the same for bot and PvP games: one constant, one code
// path, no mode-specific timing to reason about.
TurnLimit time.Duration
// GraceFor is how long a disconnected seat is held open.
GraceFor time.Duration
// RematchFor is how long a finished room waits for both players to ask for
// another game. Zero falls back to a built-in default.
RematchFor time.Duration
// AllowedOrigins is matched by coder/websocket against the Origin header.
// Empty means same-origin only, which is the right default for a binary
// that also serves the frontend.
AllowedOrigins []string
// WebDir is the built frontend. Empty, or missing on disk, serves the API
// alone — which is the state until phase 6 produces a bundle.
WebDir string
}
// Server wires the hub to an HTTP mux.
type Server struct {
hub *hub
mux *http.ServeMux
cancel context.CancelFunc
cfg Config
}
// NewServer builds the handler tree.
func NewServer(ctx context.Context, dict Dictionary, cfg Config) *Server {
ctx, cancel := context.WithCancel(ctx)
s := &Server{
hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.RematchFor),
mux: http.NewServeMux(),
cancel: cancel,
cfg: cfg,
}
s.mux.HandleFunc("GET /ws", s.handleWS)
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
s.mountStatic()
go s.sweepLimiters(ctx)
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
// Shutdown tells live games why they are ending, then stops the hub.
func (s *Server) Shutdown() {
s.hub.shutdown()
s.cancel()
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: s.cfg.AllowedOrigins,
})
if err != nil {
// Accept has already written the rejection, including the origin
// refusal, so there is nothing to add to the response here.
slog.Debug("websocket accept rejected", "err", err, "origin", r.Header.Get("Origin"))
return
}
sess := newSession(s.hub.ctx, conn, s.hub, clientIP(r))
sess.run()
// The token has to outlive the socket by exactly the grace window: that is
// what a reconnect presents to reclaim its seat. Dropping it here, as the
// connection ends, would make every resume fail to find its game.
s.hub.expireToken(sess.resumeToken, s.cfg.GraceFor)
}
// immutablePrefix is where SvelteKit's adapter puts content-hashed assets.
const immutablePrefix = "/_app/immutable/"
// mountStatic serves the built frontend so one binary is the whole deployment.
//
// Unknown paths fall back to index.html because the frontend is a single-page
// app: a deep link is a client route, not a server 404.
func (s *Server) mountStatic() {
if s.cfg.WebDir == "" {
return
}
index := filepath.Join(s.cfg.WebDir, "index.html")
if _, err := os.Stat(index); err != nil {
slog.Warn("no frontend to serve", "dir", s.cfg.WebDir)
return
}
root := filepath.Clean(s.cfg.WebDir)
files := http.FileServer(http.Dir(root))
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
clean := filepath.Join(root, filepath.Clean(r.URL.Path))
// A path boundary, not a string prefix: with a root of /srv/web, a
// prefix test would also accept /srv/webhooks. http.Dir re-anchors
// anyway, but the SPA fallback below stats paths directly, so this is
// the check that keeps it from being used to probe outside the bundle.
if !underRoot(root, clean) {
http.NotFound(w, r)
return
}
if info, err := os.Stat(clean); err == nil && !info.IsDir() {
// Everything under immutablePrefix carries a content hash in its
// name, so a changed file is a changed URL and the old one can be
// cached forever.
if strings.HasPrefix(r.URL.Path, immutablePrefix) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r)
return
}
// The shell names those hashed assets, so a cached copy outlives the
// deploy that renamed them and the app loads into a blank page.
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, index)
})
}
// underRoot reports whether path is root itself or lies beneath it.
func underRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
}
// clientIP is the key the join limiter counts against.
//
// RemoteAddr is deliberately the only source. Behind the reverse proxy this
// deploys under, X-Forwarded-For is attacker-controlled unless the proxy is
// known to overwrite it, and trusting it unconditionally would let one client
// spend everyone else's budget by forging the header.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// sweepLimiters keeps the per-key rate limiter from growing without bound.
func (s *Server) sweepLimiters(ctx context.Context) {
ticker := time.NewTicker(limiterIdleFor)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
s.hub.joinLimiter.sweep(now)
}
}
}