MWSE/internal/protocol/protocol.go

195 lines
5.7 KiB
Go

// Package protocol implements the WSTS (WebSocket Transport/Signal) wire format
// used by MWSE. The format is FROZEN: the TypeScript SDK in ./frontend speaks it
// verbatim, so the Go engine must encode and decode it byte-for-byte the same way
// the original Node.js server did.
//
// # Wire format
//
// Every WebSocket text frame carries a JSON array. The shape of that array is:
//
// [ message, id?, action? ]
//
// - message : an object, always present. It carries a "type" field that selects
// a handler, plus handler-specific fields.
// - id : optional. A number identifies a client-initiated request/stream
// whose response must be correlated. A string in this slot (e.g. "R") is used
// by the SDK's "fire and forget" path and produces no response.
// - action : optional. "R" = request (reply once, flagged "E" = end),
// "S" = stream (reply, flagged "C" = continue).
//
// When the server initiates a message (a "signal" such as room/joined) it sends:
//
// [ payload, signalName ]
//
// i.e. the signal name lives in the id slot as a string, which the SDK routes to
// its signal listeners.
package protocol
import (
"encoding/json"
"errors"
)
// Flags carried in the action slot of a server reply.
const (
FlagEnd = "E" // terminates a request: [resp, id, "E"]
FlagContinue = "C" // a stream chunk: [resp, id, "C"]
actionRequest = "R" // client asked for a single response
actionStream = "S" // client opened a stream
)
// ErrEmptyFrame is returned when a frame decodes to an empty array.
var ErrEmptyFrame = errors.New("protocol: empty frame")
// Message is a decoded inbound message object. Values follow Go's encoding/json
// conventions (numbers are float64, objects are map[string]any, etc.). The helper
// accessors below keep handler code readable and tolerant of missing fields.
type Message map[string]any
// Type returns the handler selector ("type" field), or "" when absent.
func (m Message) Type() string { return m.Str("type") }
// Str returns a string field, or "" if missing or not a string.
func (m Message) Str(key string) string {
if s, ok := m[key].(string); ok {
return s
}
return ""
}
// Int returns a numeric field as an int, or 0 if missing or not a number.
func (m Message) Int(key string) int {
if f, ok := m[key].(float64); ok {
return int(f)
}
return 0
}
// Bool returns a strict boolean field, or false if missing or not a bool.
func (m Message) Bool(key string) bool {
b, _ := m[key].(bool)
return b
}
// Truthy mirrors JavaScript's "!!value" coercion. The SDK frequently sends
// numeric flags (value: 1 / value: 0), so handlers that toggle state use this.
func (m Message) Truthy(key string) bool {
return jsTruthy(m[key])
}
// Get returns the raw value for a key (may be nil).
func (m Message) Get(key string) any { return m[key] }
// Has reports whether the key is present.
func (m Message) Has(key string) bool {
_, ok := m[key]
return ok
}
func jsTruthy(v any) bool {
switch t := v.(type) {
case nil:
return false
case bool:
return t
case float64:
return t != 0
case string:
return t != ""
default:
return true
}
}
// Envelope is a decoded inbound frame.
type Envelope struct {
// Message is the message object (arr[0]). May be nil if the frame did not
// carry an object there; handlers treat a nil/typeless message as MISSING_TYPE.
Message Message
// ID is the correlation id (arr[1]) when present and a number or string.
ID any
// HasID is true when arr[1] is a number or a string. This matches the Node
// server's `typeof id === 'number' || typeof id === 'string'` branch, which
// decides whether a reply may be sent at all.
HasID bool
// Action is the action flag (arr[2]): "R", "S", or "".
Action string
}
// WantsReply reports whether this envelope should produce a response, and with
// which terminating flag. It is false for fire-and-forget and broadcast frames.
func (e *Envelope) WantsReply() (flag string, ok bool) {
if !e.HasID {
return "", false
}
switch e.Action {
case actionRequest:
return FlagEnd, true
case actionStream:
return FlagContinue, true
default:
return "", false
}
}
// IsBroadcast reports whether the frame is in the "no id" branch, where the Node
// server inspected the handler result for a broadcast directive.
func (e *Envelope) IsBroadcast() bool { return !e.HasID }
// Decode parses a raw text frame into an Envelope. A frame that is not a JSON
// array, or is an empty array, is an error (the caller reports it as a message
// error, exactly as the Node server emitted 'messageError').
func Decode(data []byte) (*Envelope, error) {
var arr []json.RawMessage
if err := json.Unmarshal(data, &arr); err != nil {
return nil, err
}
if len(arr) == 0 {
return nil, ErrEmptyFrame
}
env := &Envelope{}
// arr[0] -> message object. If it is not an object, Message stays nil and the
// router will respond MISSING_TYPE, matching the Node destructuring behaviour.
var raw any
if err := json.Unmarshal(arr[0], &raw); err != nil {
return nil, err
}
if obj, ok := raw.(map[string]any); ok {
env.Message = Message(obj)
}
// arr[1] -> id. Only numbers and strings count as an id.
if len(arr) >= 2 {
var id any
if err := json.Unmarshal(arr[1], &id); err == nil {
switch id.(type) {
case float64, string:
env.HasID = true
env.ID = id
}
}
}
// arr[2] -> action flag.
if len(arr) >= 3 {
var action string
_ = json.Unmarshal(arr[2], &action)
env.Action = action
}
return env, nil
}
// Reply builds the wire value for a correlated response: [payload, id, flag].
func Reply(payload any, id any, flag string) []any {
return []any{payload, id, flag}
}
// Signal builds the wire value for a server-initiated message: [payload, name].
func Signal(name string, payload any) []any {
return []any{payload, name}
}