195 lines
5.7 KiB
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}
|
|
}
|