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

5
internal/wt/config.go Normal file
View File

@@ -0,0 +1,5 @@
package wt
import "github.com/centrifugal/centrifugo/v6/internal/configtypes"
type Config = configtypes.WebTransport

106
internal/wt/handler.go Normal file
View File

@@ -0,0 +1,106 @@
package wt
import (
"context"
"errors"
"io"
"net/http"
"time"
"github.com/centrifugal/centrifugo/v6/internal/logging"
"github.com/centrifugal/centrifuge"
"github.com/centrifugal/protocol"
"github.com/quic-go/quic-go"
"github.com/quic-go/webtransport-go"
"github.com/rs/zerolog/log"
)
// Handler for WebTransport.
type Handler struct {
config Config
server *webtransport.Server
node *centrifuge.Node
pingPong centrifuge.PingPongConfig
}
// NewHandler creates new Handler.
func NewHandler(node *centrifuge.Node, wtServer *webtransport.Server, config Config, pingPong centrifuge.PingPongConfig) *Handler {
return &Handler{config: config, server: wtServer, node: node, pingPong: pingPong}
}
const bidiStreamAcceptTimeout = 10 * time.Second
// ServeHTTP upgrades connection to WebTransport and creates centrifuge.Client from it.
func (s *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
conn, err := s.server.Upgrade(rw, r)
if err != nil {
log.Info().Str("transport", transportName).Err(err).Msg("error upgrading to webtransport")
rw.WriteHeader(http.StatusBadRequest)
return
}
rw.WriteHeader(http.StatusOK)
acceptCtx, acceptCtxCancel := context.WithTimeout(r.Context(), bidiStreamAcceptTimeout)
stream, err := conn.AcceptStream(acceptCtx)
if err != nil {
acceptCtxCancel()
log.Error().Err(err).Msg("stream accept error")
rw.WriteHeader(http.StatusBadRequest)
return
}
acceptCtxCancel()
protoType := centrifuge.ProtocolTypeJSON
if r.URL.RawQuery != "" && r.URL.Query().Get("cf_protocol") == "protobuf" {
protoType = centrifuge.ProtocolTypeProtobuf
}
transport := newWebtransportTransport(protoType, conn, stream, s.pingPong, r.ProtoMajor)
c, closeFn, err := centrifuge.NewClient(r.Context(), s.node, transport)
if err != nil {
log.Error().Err(err).Msg("error creating client")
return
}
defer func() { _ = 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())
}
// Coerce a non-positive limit to the default, matching the other transports:
// the stream decoder requires a positive limit (it panics otherwise), so an
// explicit message_size_limit of 0 (a natural "use default" choice for
// operators) or a negative value must not reach it.
messageSizeLimit := int64(s.config.MessageSizeLimit)
if messageSizeLimit <= 0 {
messageSizeLimit = defaultWebTransportMessageSizeLimit
}
var decoder protocol.StreamCommandDecoder
if protoType == centrifuge.ProtocolTypeJSON {
decoder = protocol.GetStreamCommandDecoderLimited(protocol.TypeJSON, stream, messageSizeLimit)
defer protocol.PutStreamCommandDecoder(protocol.TypeJSON, decoder)
} else {
decoder = protocol.NewProtobufStreamCommandDecoder(stream, messageSizeLimit)
defer protocol.PutStreamCommandDecoder(protocol.TypeProtobuf, decoder)
}
for {
cmd, cmdSize, err := decoder.Decode()
if err != nil {
var appErr *quic.ApplicationError
if !errors.Is(err, io.EOF) && !errors.As(err, &appErr) {
log.Error().Err(err).Str("transport", transportName).Str("client", c.ID()).Msg("error decoding command")
}
return
}
ok := c.HandleCommand(cmd, cmdSize)
if !ok {
return
}
}
}

136
internal/wt/handler_test.go Normal file
View File

@@ -0,0 +1,136 @@
package wt_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"io"
"math/big"
"net"
"net/http"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
"github.com/stretchr/testify/require"
)
// generateTestTLSCert creates a self-signed TLS certificate for testing.
func generateTestTLSCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{Organization: []string{"Test"}},
NotBefore: time.Now(),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
return tls.Certificate{
Certificate: [][]byte{certDER},
PrivateKey: key,
}
}
func TestWebTransportWithLogMiddleware(t *testing.T) {
cert := generateTestTLSCert(t)
wtServer := &webtransport.Server{
CheckOrigin: func(r *http.Request) bool { return true },
}
mux := http.NewServeMux()
mux.HandleFunc("/wt", func(w http.ResponseWriter, r *http.Request) {
session, err := wtServer.Upgrade(w, r)
if err != nil {
t.Logf("upgrade error: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
stream, err := session.AcceptStream(r.Context())
if err != nil {
t.Logf("accept stream error: %v", err)
return
}
data, err := io.ReadAll(stream)
if err != nil {
t.Logf("read error: %v", err)
return
}
_, _ = stream.Write(data)
_ = stream.Close()
})
// Wrap with LogRequest middleware — this is the key part under test.
server := &http3.Server{
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{http3.NextProtoH3},
},
Handler: middleware.LogRequest(mux),
}
webtransport.ConfigureHTTP3Server(server)
wtServer.H3 = server
udpAddr, err := net.ResolveUDPAddr("udp", "localhost:0")
require.NoError(t, err)
udpConn, err := net.ListenUDP("udp", udpAddr)
require.NoError(t, err)
port := udpConn.LocalAddr().(*net.UDPAddr).Port
servErr := make(chan error, 1)
go func() { servErr <- wtServer.Serve(udpConn) }()
defer func() {
require.NoError(t, wtServer.Close())
<-servErr
}()
d := webtransport.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
QUICConfig: &quic.Config{EnableDatagrams: true, EnableStreamResetPartialDelivery: true},
}
defer func() {
_ = d.Close()
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rsp, session, err := d.Dial(ctx, fmt.Sprintf("https://localhost:%d/wt", port), nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, rsp.StatusCode)
defer func() {
_ = session.CloseWithError(0, "")
}()
stream, err := session.OpenStream()
require.NoError(t, err)
_ = stream.SetDeadline(time.Now().Add(5 * time.Second))
msg := []byte("hello webtransport")
_, err = stream.Write(msg)
require.NoError(t, err)
require.NoError(t, stream.Close())
reply, err := io.ReadAll(stream)
require.NoError(t, err)
require.Equal(t, msg, reply)
}

167
internal/wt/transport.go Normal file
View File

@@ -0,0 +1,167 @@
package wt
import (
"sync"
"time"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifuge"
"github.com/centrifugal/protocol"
"github.com/quic-go/webtransport-go"
)
const transportName = "webtransport"
// defaultWebTransportMessageSizeLimit is used when message_size_limit is 0
// (which the stream decoder treats as unbounded). Matches the config default.
const defaultWebTransportMessageSizeLimit = 65536
type webtransportTransport struct {
mu sync.RWMutex
closeCh chan struct{}
protoType centrifuge.ProtocolType
session *webtransport.Session
stream *webtransport.Stream
pingPongConfig centrifuge.PingPongConfig
closed bool
protoMajor int
}
func newWebtransportTransport(protoType centrifuge.ProtocolType, session *webtransport.Session, stream *webtransport.Stream, pingPongConfig centrifuge.PingPongConfig, protoMajor int) *webtransportTransport {
return &webtransportTransport{
protoType: protoType,
closeCh: make(chan struct{}),
session: session,
stream: stream,
pingPongConfig: pingPongConfig,
protoMajor: protoMajor,
}
}
// Name implementation.
func (t *webtransportTransport) Name() string {
return transportName
}
// Protocol implementation.
func (t *webtransportTransport) Protocol() centrifuge.ProtocolType {
return t.protoType
}
// Unidirectional implementation.
func (t *webtransportTransport) Unidirectional() bool {
return false
}
// DisabledPushFlags ...
func (t *webtransportTransport) DisabledPushFlags() uint64 {
return 0
}
// ProtocolVersion ...
func (t *webtransportTransport) ProtocolVersion() centrifuge.ProtocolVersion {
return centrifuge.ProtocolVersion2
}
// Emulation ...
func (t *webtransportTransport) Emulation() bool {
return false
}
// PingPongConfig ...
func (t *webtransportTransport) PingPongConfig() centrifuge.PingPongConfig {
return t.pingPongConfig
}
// AcceptProtocol ...
func (t *webtransportTransport) AcceptProtocol() string {
return tools.GetAcceptProtocolLabel(t.protoMajor)
}
const writeTimeout = 1 * time.Second
// Write ...
func (t *webtransportTransport) Write(message []byte) error {
select {
case <-t.closeCh:
return nil
default:
protoType := protocol.TypeJSON
if t.protoType == centrifuge.ProtocolTypeProtobuf {
protoType = protocol.TypeProtobuf
}
encoder := protocol.GetDataEncoder(protoType)
defer protocol.PutDataEncoder(protoType, encoder)
_ = encoder.Encode(message)
_ = t.stream.SetWriteDeadline(time.Now().Add(writeTimeout))
_, err := t.stream.Write(encoder.Finish())
if err != nil {
return err
}
if protoType == protocol.TypeJSON {
// Need extra new line since WebTransport is stream-based, not frame-based.
_, err = t.stream.Write([]byte("\n"))
}
_ = t.stream.SetWriteDeadline(time.Time{})
return err
}
}
// WriteMany ...
func (t *webtransportTransport) WriteMany(messages ...[]byte) error {
select {
case <-t.closeCh:
return nil
default:
protoType := protocol.TypeJSON
if t.protoType == centrifuge.ProtocolTypeProtobuf {
protoType = protocol.TypeProtobuf
}
encoder := protocol.GetDataEncoder(protoType)
defer protocol.PutDataEncoder(protoType, encoder)
for i := range messages {
err := encoder.Encode(messages[i])
if err != nil {
return err
}
}
_ = t.stream.SetWriteDeadline(time.Now().Add(writeTimeout))
_, err := t.stream.Write(encoder.Finish())
if err != nil {
return err
}
if protoType == protocol.TypeJSON {
// Need extra new line since WebTransport is stream-based, not frame-based.
_, err = t.stream.Write([]byte("\n"))
}
_ = t.stream.SetWriteDeadline(time.Time{})
return err
}
}
// Close ...
func (t *webtransportTransport) Close(d centrifuge.Disconnect) error {
t.mu.Lock()
if t.closed {
t.mu.Unlock()
return nil
}
t.closed = true
close(t.closeCh)
t.mu.Unlock()
_ = t.stream.Close()
// TODO: remove this Sleep.
// We are closing session with SessionErrorCode below but browser client does not receive
// it for some reason. So for now we are sending Disconnect Pushes to pass disconnect code
// to a client. While sending Disconnect Push we hit https://github.com/quic-go/quic-go/issues/3291 –
// adding Sleep give client a chance to receive it. If code sent in CloseWithError will
// reach the client we won't need Sleep here and can disable sending Disconnect Pushes.
time.Sleep(time.Second)
return t.session.CloseWithError(webtransport.SessionErrorCode(d.Code), d.Reason)
}