Files

146 lines
4.2 KiB
Go

// ABOUTME: ServerConn wraps a WebSocket for server-side typed message sending
// ABOUTME: CGO-free alternative to sendspin.ServerClient for adapters and tools
package protocol
import (
"encoding/binary"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ServerConn wraps an accepted WebSocket connection with typed Send and
// SendBinary methods plus a background writer goroutine. It is the
// lightweight, CGO-free counterpart to sendspin.ServerClient — use it
// when you need typed protocol I/O without the full server runtime
// (e.g., conformance adapters, CLI tools, test harnesses).
//
// The zero value is not usable — construct via NewServerConn.
type ServerConn struct {
conn *websocket.Conn
id string
name string
sendChan chan interface{}
done chan struct{}
once sync.Once
}
// NewServerConn wraps an existing WebSocket connection and starts a
// background writer goroutine. Call Close() when done to stop the
// writer. Does NOT close the underlying WebSocket — the caller owns
// that lifecycle.
func NewServerConn(conn *websocket.Conn, id, name string) *ServerConn {
sc := &ServerConn{
conn: conn,
id: id,
name: name,
sendChan: make(chan interface{}, 100),
done: make(chan struct{}),
}
go sc.runWriter()
return sc
}
// ID returns the identifier passed to NewServerConn.
func (sc *ServerConn) ID() string { return sc.id }
// Name returns the name passed to NewServerConn.
func (sc *ServerConn) Name() string { return sc.name }
// Send enqueues a typed control message (JSON envelope) for
// transmission. Returns an error immediately if the send buffer is
// full. The message is wrapped in a {"type": msgType, "payload": ...}
// envelope by the writer goroutine.
func (sc *ServerConn) Send(msgType string, payload interface{}) error {
msg := Message{
Type: msgType,
Payload: payload,
}
select {
case sc.sendChan <- msg:
return nil
default:
return fmt.Errorf("send buffer full")
}
}
// SendBinary enqueues a raw binary frame for transmission. Returns an
// error immediately if the send buffer is full.
func (sc *ServerConn) SendBinary(data []byte) error {
select {
case sc.sendChan <- data:
return nil
default:
return fmt.Errorf("send buffer full")
}
}
// Close stops the writer goroutine. Safe to call multiple times. Does
// NOT close the underlying WebSocket connection.
func (sc *ServerConn) Close() {
sc.once.Do(func() {
close(sc.done)
})
}
func (sc *ServerConn) runWriter() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
const writeDeadline = 10 * time.Second
for {
select {
case msg := <-sc.sendChan:
switch v := msg.(type) {
case []byte:
sc.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := sc.conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
return
}
default:
data, err := json.Marshal(v)
if err != nil {
continue
}
sc.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := sc.conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
}
case <-ticker.C:
if err := sc.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
return
}
case <-sc.done:
return
}
}
}
// CreateAudioChunk packs timestamp + payload into a Sendspin binary audio
// frame: [1 byte message type][8 byte big-endian timestamp (µs)][audio bytes].
func CreateAudioChunk(timestamp int64, audioData []byte) []byte {
chunk := make([]byte, BinaryMessageHeaderSize+len(audioData))
chunk[0] = AudioChunkMessageType
binary.BigEndian.PutUint64(chunk[1:BinaryMessageHeaderSize], uint64(timestamp))
copy(chunk[BinaryMessageHeaderSize:], audioData)
return chunk
}
// CreateArtworkChunk packs an artwork frame: [1 byte message type][8 byte
// timestamp (µs)][image bytes]. Channel is 0-3, mapping to the artwork
// channel message types (8-11).
func CreateArtworkChunk(channel int, timestamp int64, imageData []byte) []byte {
chunk := make([]byte, BinaryMessageHeaderSize+len(imageData))
chunk[0] = byte(ArtworkChannel0MessageType + channel)
binary.BigEndian.PutUint64(chunk[1:BinaryMessageHeaderSize], uint64(timestamp))
copy(chunk[BinaryMessageHeaderSize:], imageData)
return chunk
}