snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
7
internal/unisse/config.go
Normal file
7
internal/unisse/config.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package unisse
|
||||
|
||||
import (
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
)
|
||||
|
||||
type Config = configtypes.UniSSE
|
||||
176
internal/unisse/handler.go
Normal file
176
internal/unisse/handler.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package unisse
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/convert"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/logging"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/centrifugal/protocol"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
node *centrifuge.Node
|
||||
config Config
|
||||
pingPong centrifuge.PingPongConfig
|
||||
}
|
||||
|
||||
func NewHandler(n *centrifuge.Node, c Config, pingPong centrifuge.PingPongConfig) *Handler {
|
||||
return &Handler{
|
||||
node: n,
|
||||
config: c,
|
||||
pingPong: pingPong,
|
||||
}
|
||||
}
|
||||
|
||||
// Since SSE is a GET request we are looking for connect request in URL params.
|
||||
// This should be a properly encoded JSON object.
|
||||
const connectUrlParam = "cf_connect"
|
||||
|
||||
const streamWriteTimeout = time.Second
|
||||
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "expected http.ResponseWriter to be http.Flusher", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var req *protocol.ConnectRequest
|
||||
if r.Method == http.MethodGet {
|
||||
connectRequestString := r.URL.Query().Get(connectUrlParam)
|
||||
if connectRequestString != "" {
|
||||
_, err := json.Parse(convert.StringToBytes(connectRequestString), &req, json.ZeroCopy)
|
||||
if err != nil {
|
||||
log.Info().Err(err).Str("transport", transportName).Msg("error unmarshalling connect request")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
req = &protocol.ConnectRequest{}
|
||||
}
|
||||
} else if r.Method == http.MethodPost {
|
||||
maxBytesSize := h.config.MaxRequestBodySize
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytesSize))
|
||||
connectRequestData, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error reading uni sse request body")
|
||||
if len(connectRequestData) >= maxBytesSize {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
_, err = json.Parse(connectRequestData, &req, json.ZeroCopy)
|
||||
if err != nil {
|
||||
if logging.Enabled(logging.DebugLevel) {
|
||||
log.Debug().Err(err).Str("transport", transportName).Msg("malformed connect request")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
// A literal `null` connect request decodes to a nil request with no error;
|
||||
// treat it as an empty request rather than passing nil to the connect
|
||||
// handler, which would nil-deref.
|
||||
req = &protocol.ConnectRequest{}
|
||||
}
|
||||
|
||||
ack := make(chan struct{})
|
||||
transport := newEventsourceTransport(r, h.pingPong, ack)
|
||||
c, closeFn, err := centrifuge.NewClient(r.Context(), h.node, transport)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("transport", transportName).Msg("error create client")
|
||||
return
|
||||
}
|
||||
defer func() { _ = closeFn() }()
|
||||
defer close(transport.closedCh) // need to execute this after client closeFn.
|
||||
|
||||
if logging.Enabled(logging.DebugLevel) {
|
||||
log.Debug().Str("transport", transportName).Str("client", c.ID()).Msg("client connection established")
|
||||
defer func(started time.Time) {
|
||||
log.Debug().Str("transport", transportName).Str("client", c.ID()).
|
||||
Str("duration", time.Since(started).String()).Msg("client connection completed")
|
||||
}(time.Now())
|
||||
}
|
||||
|
||||
if h.config.ConnectCodeToHTTPResponse.Enabled {
|
||||
err = c.ProtocolConnectNoErrorToDisconnect(req)
|
||||
if err != nil {
|
||||
resp, ok := tools.ConnectErrorToToHTTPResponse(err, h.config.ConnectCodeToHTTPResponse.Transforms)
|
||||
if ok {
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write([]byte(resp.Body))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(http.StatusText(http.StatusInternalServerError)))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.ProtocolConnect(req)
|
||||
}
|
||||
|
||||
if r.ProtoMajor == 1 {
|
||||
// An endpoint MUST NOT generate an HTTP/2 message containing connection-specific header fields.
|
||||
// Source: RFC7540.
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
}
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expire", "0")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(streamWriteTimeout))
|
||||
_, err = w.Write([]byte("\r\n"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = rc.Flush()
|
||||
|
||||
sendAck := func() {
|
||||
select {
|
||||
case ack <- struct{}{}:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-transport.disconnectCh:
|
||||
return
|
||||
case messages, messagesOK := <-transport.messages:
|
||||
if !messagesOK {
|
||||
sendAck()
|
||||
return
|
||||
}
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(streamWriteTimeout))
|
||||
for _, msg := range messages {
|
||||
_, err = w.Write(convert.StringToBytes("data: " + convert.BytesToString(msg) + "\n\n"))
|
||||
if err != nil {
|
||||
sendAck()
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = rc.Flush()
|
||||
_ = rc.SetWriteDeadline(time.Time{})
|
||||
sendAck()
|
||||
}
|
||||
}
|
||||
}
|
||||
189
internal/unisse/handler_test.go
Normal file
189
internal/unisse/handler_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package unisse
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/middleware"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/centrifugal/protocol"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func ensureSSEMessageHasClient(t *testing.T, data string) {
|
||||
t.Log(data)
|
||||
// SSE messages come in the format "data: {...}\n\n".
|
||||
if !strings.HasPrefix(data, "data: ") {
|
||||
t.Fatalf("Expected SSE message to start with 'data: ', got: %s (len: %d)", data, len(data))
|
||||
}
|
||||
jsonData := strings.TrimPrefix(data, "data: ")
|
||||
jsonData = strings.TrimSpace(jsonData)
|
||||
|
||||
var reply protocol.Reply
|
||||
err := json.Unmarshal([]byte(jsonData), &reply)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, reply.Connect.Client)
|
||||
}
|
||||
|
||||
func readSSEMessage(reader *bufio.Reader) (string, error) {
|
||||
var lines []string
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
// Empty line marks end of SSE message
|
||||
if len(lines) > 0 {
|
||||
break
|
||||
}
|
||||
// Skip initial empty lines (like the \r\n at the beginning)
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func TestUnidirectionalSSE(t *testing.T) {
|
||||
t.Parallel()
|
||||
node, err := centrifuge.New(centrifuge.Config{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = node.Shutdown(context.Background()) })
|
||||
|
||||
node.OnConnecting(func(ctx context.Context, event centrifuge.ConnectEvent) (centrifuge.ConnectReply, error) {
|
||||
return centrifuge.ConnectReply{
|
||||
Credentials: ¢rifuge.Credentials{},
|
||||
}, nil
|
||||
})
|
||||
|
||||
config := configtypes.UniSSE{
|
||||
MaxRequestBodySize: 65536,
|
||||
}
|
||||
|
||||
pingPong := centrifuge.PingPongConfig{
|
||||
PingInterval: 5 * time.Second,
|
||||
PongTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
handler := NewHandler(node, config, pingPong)
|
||||
|
||||
server := httptest.NewServer(middleware.LogRequest(handler))
|
||||
t.Cleanup(func() { server.Close() })
|
||||
|
||||
t.Run("successful connection with URL params", func(t *testing.T) {
|
||||
connectReq := `{}`
|
||||
params := url.Values{}
|
||||
params.Set(connectUrlParam, connectReq)
|
||||
|
||||
resp, err := http.Get(server.URL + "?" + params.Encode())
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "text/event-stream; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||
require.Contains(t, resp.Header.Get("Cache-Control"), "no-cache")
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
message, err := readSSEMessage(reader)
|
||||
require.NoError(t, err)
|
||||
ensureSSEMessageHasClient(t, message)
|
||||
})
|
||||
|
||||
t.Run("successful connection with POST body", func(t *testing.T) {
|
||||
connectReq := `{}`
|
||||
resp, err := http.Post(server.URL, "application/json", strings.NewReader(connectReq))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "text/event-stream; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
message, err := readSSEMessage(reader)
|
||||
require.NoError(t, err)
|
||||
ensureSSEMessageHasClient(t, message)
|
||||
})
|
||||
|
||||
t.Run("invalid connect request in URL params", func(t *testing.T) {
|
||||
params := url.Values{}
|
||||
params.Set(connectUrlParam, "invalid-json")
|
||||
|
||||
resp, err := http.Get(server.URL + "?" + params.Encode())
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("invalid connect request in POST body", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL, "application/json", strings.NewReader("invalid-json"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("unsupported method", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPut, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("connect request without parameters", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "text/event-stream; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
message, err := readSSEMessage(reader)
|
||||
require.NoError(t, err)
|
||||
ensureSSEMessageHasClient(t, message)
|
||||
})
|
||||
|
||||
t.Run("ping cycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp, err := http.Get(server.URL)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
|
||||
// Read connect response
|
||||
message, err := readSSEMessage(reader)
|
||||
require.NoError(t, err)
|
||||
ensureSSEMessageHasClient(t, message)
|
||||
|
||||
for {
|
||||
message, err := readSSEMessage(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(message, "data: {}") {
|
||||
t.Logf("centrifugal protocol ping received: %s", message)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
108
internal/unisse/transport.go
Normal file
108
internal/unisse/transport.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package unisse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
)
|
||||
|
||||
type eventsourceTransport struct {
|
||||
mu sync.Mutex
|
||||
req *http.Request
|
||||
ack chan struct{}
|
||||
messages chan [][]byte
|
||||
disconnectCh chan *centrifuge.Disconnect
|
||||
closedCh chan struct{}
|
||||
closed bool
|
||||
pingPongConfig centrifuge.PingPongConfig
|
||||
protoMajor int
|
||||
}
|
||||
|
||||
func newEventsourceTransport(req *http.Request, pingPongConfig centrifuge.PingPongConfig, ack chan struct{}) *eventsourceTransport {
|
||||
return &eventsourceTransport{
|
||||
messages: make(chan [][]byte),
|
||||
disconnectCh: make(chan *centrifuge.Disconnect),
|
||||
closedCh: make(chan struct{}),
|
||||
req: req,
|
||||
pingPongConfig: pingPongConfig,
|
||||
protoMajor: req.ProtoMajor,
|
||||
ack: ack,
|
||||
}
|
||||
}
|
||||
|
||||
const transportName = "uni_sse"
|
||||
|
||||
func (t *eventsourceTransport) Name() string {
|
||||
return transportName
|
||||
}
|
||||
|
||||
func (t *eventsourceTransport) Protocol() centrifuge.ProtocolType {
|
||||
return centrifuge.ProtocolTypeJSON
|
||||
}
|
||||
|
||||
// ProtocolVersion returns transport protocol version.
|
||||
func (t *eventsourceTransport) ProtocolVersion() centrifuge.ProtocolVersion {
|
||||
return centrifuge.ProtocolVersion2
|
||||
}
|
||||
|
||||
// Unidirectional returns whether transport is unidirectional.
|
||||
func (t *eventsourceTransport) Unidirectional() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// DisabledPushFlags ...
|
||||
func (t *eventsourceTransport) DisabledPushFlags() uint64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// PingPongConfig ...
|
||||
func (t *eventsourceTransport) PingPongConfig() centrifuge.PingPongConfig {
|
||||
return t.pingPongConfig
|
||||
}
|
||||
|
||||
// Emulation ...
|
||||
func (t *eventsourceTransport) Emulation() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AcceptProtocol ...
|
||||
func (t *eventsourceTransport) AcceptProtocol() string {
|
||||
return tools.GetAcceptProtocolLabel(t.protoMajor)
|
||||
}
|
||||
|
||||
func (t *eventsourceTransport) Write(message []byte) error {
|
||||
return t.WriteMany(message)
|
||||
}
|
||||
|
||||
func (t *eventsourceTransport) WriteMany(messages ...[]byte) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case t.messages <- messages:
|
||||
case <-t.closedCh:
|
||||
}
|
||||
select {
|
||||
case <-t.ack:
|
||||
case <-t.closedCh:
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *eventsourceTransport) Close(_ centrifuge.Disconnect) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed {
|
||||
return nil
|
||||
}
|
||||
t.closed = true
|
||||
close(t.disconnectCh)
|
||||
<-t.closedCh
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user