snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
package unihttpstream
import (
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
)
type Config = configtypes.UniHTTPStream

View File

@@ -0,0 +1,162 @@
package unihttpstream
import (
"io"
"net/http"
"time"
"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,
}
}
const streamWriteTimeout = time.Second
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Max-Age", "300")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
_, ok := w.(http.Flusher)
if !ok {
http.Error(w, "expected http.ResponseWriter to be http.Flusher", http.StatusInternalServerError)
return
}
var req *protocol.ConnectRequest
maxBytesSize := int64(h.config.MaxRequestBodySize)
r.Body = http.MaxBytesReader(w, r.Body, maxBytesSize)
connectRequestData, err := io.ReadAll(r.Body)
if err != nil {
log.Info().Err(err).Str("transport", transportName).Msg("error reading uni http stream request body")
if len(connectRequestData) >= int(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
}
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 := newStreamTransport(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("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)
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(msg)
if err != nil {
sendAck()
return
}
_, err = w.Write([]byte("\n"))
if err != nil {
sendAck()
return
}
}
_ = rc.Flush()
_ = rc.SetWriteDeadline(time.Time{})
sendAck()
}
}
}

View File

@@ -0,0 +1,178 @@
package unihttpstream
import (
"bufio"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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 ensureHTTPStreamMessageHasClient(t *testing.T, data string) {
t.Log(data)
var reply protocol.Reply
err := json.Unmarshal([]byte(data), &reply)
require.NoError(t, err)
require.NotEmpty(t, reply.Connect.Client)
}
func readHTTPStreamMessage(reader *bufio.Reader) (string, error) {
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(line), nil
}
func TestUnidirectionalHTTPStream(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: &centrifuge.Credentials{},
}, nil
})
config := configtypes.UniHTTPStream{
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 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/plain; charset=utf-8", resp.Header.Get("Content-Type"))
reader := bufio.NewReader(resp.Body)
message, err := readHTTPStreamMessage(reader)
require.NoError(t, err)
ensureHTTPStreamMessageHasClient(t, message)
})
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("empty POST body", func(t *testing.T) {
resp, err := http.Post(server.URL, "application/json", strings.NewReader(""))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GET method not allowed", func(t *testing.T) {
resp, err := http.Get(server.URL)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
})
t.Run("PUT method not allowed", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPut, server.URL, strings.NewReader("{}"))
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("OPTIONS method", func(t *testing.T) {
req, err := http.NewRequest(http.MethodOptions, 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.StatusNoContent, resp.StatusCode)
require.Equal(t, "300", resp.Header.Get("Access-Control-Max-Age"))
require.Equal(t, "POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
})
t.Run("large request body", func(t *testing.T) {
// Create a request body larger than typical limits
largeBody := strings.Repeat("a", 100000)
connectReq := `{"data":"` + largeBody + `"}`
resp, err := http.Post(server.URL, "application/json", strings.NewReader(connectReq))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// Should either work or return an appropriate error status
if resp.StatusCode != http.StatusOK {
require.True(t, resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusRequestEntityTooLarge)
}
})
t.Run("ping cycle", func(t *testing.T) {
t.Parallel()
resp, err := http.Post(server.URL, "application/json", strings.NewReader("{}"))
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 := readHTTPStreamMessage(reader)
require.NoError(t, err)
ensureHTTPStreamMessageHasClient(t, message)
for {
message, err = readHTTPStreamMessage(reader)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(message) == "{}" {
t.Logf("centrifugal protocol ping received: %s", message)
return
}
}
})
t.Run("malformed JSON in body", func(t *testing.T) {
malformedJSON := `{"incomplete": json`
resp, err := http.Post(server.URL, "application/json", strings.NewReader(malformedJSON))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
}

View File

@@ -0,0 +1,108 @@
package unihttpstream
import (
"net/http"
"sync"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
)
type streamTransport 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 newStreamTransport(req *http.Request, pingPongConfig centrifuge.PingPongConfig, ack chan struct{}) *streamTransport {
return &streamTransport{
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_http_stream"
func (t *streamTransport) Name() string {
return transportName
}
func (t *streamTransport) Protocol() centrifuge.ProtocolType {
return centrifuge.ProtocolTypeJSON
}
// ProtocolVersion returns transport protocol version.
func (t *streamTransport) ProtocolVersion() centrifuge.ProtocolVersion {
return centrifuge.ProtocolVersion2
}
// Unidirectional returns whether transport is unidirectional.
func (t *streamTransport) Unidirectional() bool {
return true
}
// DisabledPushFlags ...
func (t *streamTransport) DisabledPushFlags() uint64 {
return 0
}
// PingPongConfig ...
func (t *streamTransport) PingPongConfig() centrifuge.PingPongConfig {
return t.pingPongConfig
}
// Emulation ...
func (t *streamTransport) Emulation() bool {
return false
}
// AcceptProtocol ...
func (t *streamTransport) AcceptProtocol() string {
return tools.GetAcceptProtocolLabel(t.protoMajor)
}
func (t *streamTransport) Write(message []byte) error {
return t.WriteMany(message)
}
func (t *streamTransport) 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 *streamTransport) 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
}