mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-05 20:14:59 +00:00
A room used to be a wrapper around one game: joining started it, and the room died with it unless both players accepted a rematch inside thirty seconds. It is now a lobby that outlives its games. Whoever created the room owns it and the other seat is the guest. The guest readies and the owner starts; the owner has no readiness of their own, because starting is the same statement. A finished game returns both to the lobby, where the next one is agreed exactly as the last was — the readiness that started a game is spent with it. A guest takes their readiness back before leaving, which is deliberate friction: a player the owner is waiting on should have to say so before walking away. The owner can free the seat of a guest who is not ready, and not of one who is — readiness is a commitment, not an inconvenience. An owner who leaves hands the room to whoever is left, unreadied, because they are the one who starts now. Something has to bound a room that outlives its games: the last player out closes it, as does ten minutes in a lobby nobody started a game in. A dropped connection is still not a player leaving — the seat is held for the reconnect window in the lobby as well as mid-game, so a refresh no longer costs somebody their room, and a resume lands in the lobby it left. The rematch handshake is retired, and RoomCreated and RoomJoined go with it. All three described part of what RoomState now describes in full, and three messages for one lobby is three ways for a client to hold a view of it the server never had. One snapshot per recipient, broadcast from the one place that knows an input is finished, so no handler can forget to send it.
178 lines
5.4 KiB
Go
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
|
|
// IdleFor is how long a room sits in its lobby with no game started before
|
|
// it closes. Zero falls back to a built-in default.
|
|
IdleFor 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.IdleFor),
|
|
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)
|
|
}
|
|
}
|
|
}
|