374 lines
9.3 KiB
Go
374 lines
9.3 KiB
Go
package websocket
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
gorillawebsocket "github.com/gorilla/websocket"
|
|
melody "github.com/olahol/melody"
|
|
"kra/internal/integration/runtimeconfig"
|
|
platformws "kra/pkg/websocket"
|
|
)
|
|
|
|
const ProviderMelody = "melody"
|
|
|
|
// Server owns the database-configured WebSocket endpoint.
|
|
type Server struct {
|
|
mu sync.RWMutex
|
|
current *platformws.Server
|
|
path string
|
|
messageHandlers []func(*melody.Session, []byte)
|
|
binaryHandlers []func(*melody.Session, []byte)
|
|
connectHandlers []func(*melody.Session)
|
|
disconnectHandlers []func(*melody.Session)
|
|
stop func()
|
|
closed bool
|
|
}
|
|
|
|
func New(store *runtimeconfig.Store) (*Server, func(), error) {
|
|
s := &Server{}
|
|
if store != nil {
|
|
s.apply(storeConfig(store))
|
|
s.stop = store.Subscribe("websocket", ProviderMelody, func(config runtimeconfig.Config) { s.apply(config) })
|
|
}
|
|
return s, func() {
|
|
if s.stop != nil {
|
|
s.stop()
|
|
}
|
|
s.mu.Lock()
|
|
s.closed = true
|
|
current := s.current
|
|
s.current = nil
|
|
s.path = ""
|
|
s.mu.Unlock()
|
|
if current != nil {
|
|
_ = current.Close()
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
func storeConfig(store *runtimeconfig.Store) runtimeconfig.Config {
|
|
config, _ := store.Get("websocket", ProviderMelody)
|
|
return config
|
|
}
|
|
|
|
// TestConfig performs a local WebSocket handshake using a temporary server
|
|
// built from the candidate settings. It does not touch the live endpoint.
|
|
func TestConfig(ctx context.Context, raw json.RawMessage) error {
|
|
values := map[string]any{}
|
|
if err := json.Unmarshal(raw, &values); err != nil {
|
|
return fmt.Errorf("decode websocket configuration: %w", err)
|
|
}
|
|
path := text(values, "path")
|
|
if path == "" {
|
|
path = "/ws"
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
temporary := platformws.New(platformws.Config{
|
|
WriteWait: durationValue(values, "write_wait", 10*time.Second),
|
|
PongWait: durationValue(values, "pong_wait", 60*time.Second),
|
|
PingPeriod: durationValue(values, "ping_period", 54*time.Second),
|
|
MaxMessageSize: int64Value(values, "max_message_size"),
|
|
MessageBufferSize: int(intValue(values, "message_buffer_size")),
|
|
ConcurrentMessageHandling: boolValue(values, "concurrent_message_handling"),
|
|
AllowOrigins: stringList(values, "allow_origins"),
|
|
})
|
|
defer temporary.Close()
|
|
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != path {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := temporary.HandleRequest(w, r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
}))
|
|
defer httpServer.Close()
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
headers := http.Header{}
|
|
origins := stringList(values, "allow_origins")
|
|
if len(origins) > 0 {
|
|
origin := origins[0]
|
|
if origin == "*" {
|
|
origin = "http://localhost"
|
|
}
|
|
headers.Set("Origin", origin)
|
|
}
|
|
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + path
|
|
connection, response, err := gorillawebsocket.DefaultDialer.DialContext(ctx, wsURL, headers)
|
|
if response != nil && response.Body != nil {
|
|
_ = response.Body.Close()
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("websocket handshake failed: %w", err)
|
|
}
|
|
if connection == nil {
|
|
return errors.New("websocket handshake returned an empty connection")
|
|
}
|
|
return connection.Close()
|
|
}
|
|
|
|
func (s *Server) apply(config runtimeconfig.Config) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
values := map[string]any{}
|
|
if len(config.Values) > 0 {
|
|
if err := json.Unmarshal(config.Values, &values); err != nil {
|
|
return
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
previous := s.current
|
|
if !config.Enabled {
|
|
s.current = nil
|
|
s.path = ""
|
|
s.mu.Unlock()
|
|
if previous != nil {
|
|
_ = previous.Close()
|
|
}
|
|
return
|
|
}
|
|
path := text(values, "path")
|
|
if path == "" {
|
|
path = "/ws"
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
next := platformws.New(platformws.Config{
|
|
WriteWait: durationValue(values, "write_wait", 10*time.Second),
|
|
PongWait: durationValue(values, "pong_wait", 60*time.Second),
|
|
PingPeriod: durationValue(values, "ping_period", 54*time.Second),
|
|
MaxMessageSize: int64Value(values, "max_message_size"),
|
|
MessageBufferSize: int(intValue(values, "message_buffer_size")),
|
|
ConcurrentMessageHandling: boolValue(values, "concurrent_message_handling"),
|
|
AllowOrigins: stringList(values, "allow_origins"),
|
|
})
|
|
for _, handler := range s.messageHandlers {
|
|
next.OnMessage(handler)
|
|
}
|
|
for _, handler := range s.binaryHandlers {
|
|
next.OnBinaryMessage(handler)
|
|
}
|
|
for _, handler := range s.connectHandlers {
|
|
next.OnConnect(handler)
|
|
}
|
|
for _, handler := range s.disconnectHandlers {
|
|
next.OnDisconnect(handler)
|
|
}
|
|
s.current = next
|
|
s.path = path
|
|
s.mu.Unlock()
|
|
if previous != nil {
|
|
_ = previous.Close()
|
|
}
|
|
}
|
|
|
|
func text(values map[string]any, key string) string {
|
|
value, ok := values[key]
|
|
if !ok || value == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
|
|
func intValue(values map[string]any, key string) int64 {
|
|
switch value := values[key].(type) {
|
|
case float64:
|
|
return int64(value)
|
|
case int:
|
|
return int64(value)
|
|
case json.Number:
|
|
parsed, _ := strconv.ParseInt(string(value), 10, 64)
|
|
return parsed
|
|
default:
|
|
parsed, _ := strconv.ParseInt(text(values, key), 10, 64)
|
|
return parsed
|
|
}
|
|
}
|
|
|
|
func int64Value(values map[string]any, key string) int64 { return intValue(values, key) }
|
|
func boolValue(values map[string]any, key string) bool { value, _ := values[key].(bool); return value }
|
|
func stringList(values map[string]any, key string) []string {
|
|
value, ok := values[key].([]any)
|
|
if ok {
|
|
result := make([]string, 0, len(value))
|
|
for _, item := range value {
|
|
if item != nil && strings.TrimSpace(fmt.Sprint(item)) != "" {
|
|
result = append(result, strings.TrimSpace(fmt.Sprint(item)))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
if value, ok := values[key].([]string); ok {
|
|
return append([]string(nil), value...)
|
|
}
|
|
return nil
|
|
}
|
|
func durationValue(values map[string]any, key string, fallback time.Duration) time.Duration {
|
|
value := text(values, key)
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func (s *Server) Enabled() bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.current != nil
|
|
}
|
|
func (s *Server) Path() string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.path
|
|
}
|
|
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return current.HandleRequest(w, r)
|
|
}
|
|
func (s *Server) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, keys map[string]any) error {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return current.HandleRequestWithKeys(w, r, keys)
|
|
}
|
|
func (s *Server) Broadcast(message []byte) error {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return current.Broadcast(message)
|
|
}
|
|
func (s *Server) BroadcastBinary(message []byte) error {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return current.BroadcastBinary(message)
|
|
}
|
|
func (s *Server) Sessions() ([]*melody.Session, error) {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return current.Sessions()
|
|
}
|
|
func (s *Server) Len() int {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return current.Len()
|
|
}
|
|
func (s *Server) Send(session *melody.Session, message []byte) error {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return current.Send(session, message)
|
|
}
|
|
func (s *Server) SendBinary(session *melody.Session, message []byte) error {
|
|
current, err := s.active()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return current.SendBinary(session, message)
|
|
}
|
|
func (s *Server) active() (*platformws.Server, error) {
|
|
if s == nil {
|
|
return nil, errors.New("websocket server is disabled")
|
|
}
|
|
s.mu.RLock()
|
|
current := s.current
|
|
s.mu.RUnlock()
|
|
if current == nil {
|
|
return nil, errors.New("websocket server is disabled")
|
|
}
|
|
return current, nil
|
|
}
|
|
func (s *Server) OnMessage(handler func(*melody.Session, []byte)) {
|
|
if s == nil || handler == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.messageHandlers = append(s.messageHandlers, handler)
|
|
current := s.current
|
|
if current != nil {
|
|
current.OnMessage(handler)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
func (s *Server) OnBinaryMessage(handler func(*melody.Session, []byte)) {
|
|
if s == nil || handler == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.binaryHandlers = append(s.binaryHandlers, handler)
|
|
current := s.current
|
|
if current != nil {
|
|
current.OnBinaryMessage(handler)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
func (s *Server) OnConnect(handler func(*melody.Session)) {
|
|
if s == nil || handler == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.connectHandlers = append(s.connectHandlers, handler)
|
|
current := s.current
|
|
if current != nil {
|
|
current.OnConnect(handler)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
func (s *Server) OnDisconnect(handler func(*melody.Session)) {
|
|
if s == nil || handler == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.disconnectHandlers = append(s.disconnectHandlers, handler)
|
|
current := s.current
|
|
if current != nil {
|
|
current.OnDisconnect(handler)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|