146 lines
4.2 KiB
Go
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
|
|
}
|