// 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} }