Files
noitu/server/internal/wsapi/codec.go
T
tiennm99 4a78b8aad5 feat(server): pair strangers with quick-match
The hub keeps a FIFO of waiting sessions; a second QuickMatch pops the
first, opens a room via the existing createInput/joinInput path, and
marks it to begin its own first game once both seats are connected in
the lobby. CancelQuickMatch, a dropped connection, and entering a room
by code all remove a session from the queue. Counts queued, cancelled
and matched pairings in metrics.
2026-09-21 01:39:44 +07:00

88 lines
3.0 KiB
Go

package wsapi
import (
"errors"
"fmt"
"github.com/coder/websocket"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"google.golang.org/protobuf/proto"
)
// maxFrameBytes caps what a client may send.
//
// The largest legitimate client frame is a Hello carrying a 20-rune nickname,
// well under 200 bytes. 4 KiB leaves room for the protocol to grow while still
// making a memory-exhaustion attempt fail at the socket rather than in the
// decoder.
const maxFrameBytes = 4096
// ErrNotBinary rejects text frames. The protocol is protobuf over binary
// frames only; a text frame means the peer is speaking something else, and
// guessing at it is how a parser becomes an attack surface.
var ErrNotBinary = errors.New("wsapi: expected a binary frame")
// Encode marshals a server message for the wire.
func Encode(m *noituv1.ServerMessage) ([]byte, error) {
raw, err := proto.Marshal(m)
if err != nil {
return nil, fmt.Errorf("wsapi: encode: %w", err)
}
return raw, nil
}
// Decode parses a client frame.
//
// typ is checked here rather than at the call site so every entry point gets
// the same rejection.
func Decode(typ websocket.MessageType, raw []byte) (*noituv1.ClientMessage, error) {
if typ != websocket.MessageBinary {
return nil, ErrNotBinary
}
var m noituv1.ClientMessage
if err := proto.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("wsapi: decode: %w", err)
}
return &m, nil
}
// Message-building helpers. Every server message is constructed through one of
// these, so the oneof wrapper is written once per variant instead of at each
// call site.
func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage {
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{
SessionId: sessionID,
ResumeToken: resumeToken,
ProtocolVersion: ProtocolVersion,
AcceptedNickname: nickname,
}}}
}
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage {
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq},
}}
}
// errorMsg carries a UI key, never prose and never an internal error string.
// The Vietnamese copy lives in the frontend so all wording stays in one place,
// and a raw error would leak server internals to anyone with a socket.
func errorMsg(code string) *noituv1.ServerMessage {
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Error{
Error: &noituv1.ServerError{Code: code, Message: code},
}}
}
func pongMsg(clientTimeMs, serverTimeMs int64) *noituv1.ServerMessage {
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_Pong{
Pong: &noituv1.Pong{ClientTimeMs: clientTimeMs, ServerTimeMs: serverTimeMs},
}}
}
func quickMatchStatusMsg(queued bool) *noituv1.ServerMessage {
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_QuickMatchStatus{
QuickMatchStatus: &noituv1.QuickMatchStatus{Queued: queued},
}}
}