Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions sei-cosmos/server/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,12 @@ func (s *Server) Start(cfg config.Config, apiMetrics *telemetry.Metrics) error {
if cfg.API.EnableUnsafeCORS {
allowAllCORS := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"}))
s.mtx.Unlock()
return tmrpcserver.Serve(context.Background(), s.listener, allowAllCORS(h), tmCfg)
return tmrpcserver.Serve(context.Background(), s.listener, tmrpcserver.NewGzipHandler(allowAllCORS(h)), tmCfg)
}

logger.Info("starting API server...")
s.mtx.Unlock()
return tmrpcserver.Serve(context.Background(), s.listener, s.Router, tmCfg)
return tmrpcserver.Serve(context.Background(), s.listener, tmrpcserver.NewGzipHandler(s.Router), tmCfg)
}

// Close closes the API server.
Expand Down
2 changes: 1 addition & 1 deletion sei-tendermint/internal/inspect/rpc/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler {
if rpcConfig.IsCorsEnabled() {
rootHandler = addCORSHandler(rpcConfig, mux)
}
return rootHandler
return server.NewGzipHandler(rootHandler)
}

func addCORSHandler(rpcConfig *config.RPCConfig, h http.Handler) http.Handler {
Expand Down
1 change: 1 addition & 0 deletions sei-tendermint/internal/rpc/core/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) (
})
rootHandler = corsMiddleware.Handler(mux)
}
rootHandler = rpcserver.NewGzipHandler(rootHandler)
if conf.RPC.IsTLSEnabled() {
go func() {
if err := rpcserver.ServeTLS(
Expand Down
210 changes: 210 additions & 0 deletions sei-tendermint/rpc/jsonrpc/server/gzip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package server

import (
"bufio"
"compress/gzip"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"sync"
)

const minCompressBytes = 1024

var gzPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed)
return w
Comment thread
cursor[bot] marked this conversation as resolved.
},
}

type gzipResponseWriter struct {
resp http.ResponseWriter

gz *gzip.Writer
contentLength uint64
hasLength bool
inited bool
}

func (w *gzipResponseWriter) init() {
if w.inited {
return
}
w.inited = true

hdr := w.resp.Header()
length := hdr.Get("content-length")
if len(length) > 0 {
if n, err := strconv.ParseUint(length, 10, 64); err == nil {
w.hasLength = true
w.contentLength = n
}
}

// Skip compression if the inner handler already encoded the response or
// explicitly opted out via Transfer-Encoding: identity.
if hdr.Get("content-encoding") != "" || hdr.Get("transfer-encoding") == "identity" {
return
}

// Skip compression for small responses with a known Content-Length; below
// the threshold the gzip overhead outweighs the savings and the CPU cost
// per byte is not worth it for unauthenticated callers.
if w.hasLength && w.contentLength < minCompressBytes {
return
}

w.gz = gzPool.Get().(*gzip.Writer)
w.gz.Reset(w.resp)
hdr.Del("content-length")
hdr.Set("content-encoding", "gzip")
}
Comment thread
cursor[bot] marked this conversation as resolved.

func (w *gzipResponseWriter) Header() http.Header {
return w.resp.Header()
}

func (w *gzipResponseWriter) WriteHeader(status int) {
// Bodyless responses must not be compressed — gzip would write a stream
// terminator into what must be an empty body (RFC 7230 §3.3).
if status == http.StatusNoContent || status == http.StatusNotModified ||
(status >= 100 && status <= 199) {
w.inited = true // skip gzip setup
w.resp.WriteHeader(status)
return
}
w.init()
w.resp.WriteHeader(status)
}

func (w *gzipResponseWriter) Write(b []byte) (int, error) {
w.init()

if w.gz == nil {
return w.resp.Write(b)
}

return w.gz.Write(b)
}

func (w *gzipResponseWriter) Flush() {
if w.gz != nil {
_ = w.gz.Flush()
}
if f, ok := w.resp.(http.Flusher); ok {
f.Flush()
}
}

// Hijack implements http.Hijacker by forwarding to the inner ResponseWriter.
// The gzip writer is closed first so the hijacked connection starts clean.
func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h, ok := w.resp.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("gzip middleware: underlying ResponseWriter does not implement http.Hijacker")
}
w.close()
return h.Hijack()
}

func (w *gzipResponseWriter) close() {
if w.gz == nil {
return
}
_ = w.gz.Close()
gzPool.Put(w.gz)
w.gz = nil
}

// acceptsGzip reports whether the request advertises gzip encoding support,
// respecting q-values and the "*" wildcard per RFC 7231 §5.3.4.
func acceptsGzip(r *http.Request) bool {
gzipQ := -1.0
starQ := -1.0
for _, field := range r.Header["Accept-Encoding"] {
for part := range strings.SplitSeq(field, ",") {
part = strings.TrimSpace(part)
coding, params, _ := strings.Cut(part, ";")
coding = strings.ToLower(strings.TrimSpace(coding))
q := 1.0
for p := range strings.SplitSeq(params, ";") {
p = strings.TrimSpace(p)
if k, v, ok := strings.Cut(p, "="); ok && strings.ToLower(strings.TrimSpace(k)) == "q" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
q = f
}
}
}
switch coding {
case "gzip":
gzipQ = q
case "*":
starQ = q
}
}
}
if gzipQ >= 0 {
return gzipQ > 0
}
if starQ >= 0 {
return starQ > 0
}
return false
}

// ensureVaryAcceptEncoding appends Accept-Encoding to the Vary header exactly
// once, deduplicating against any value already set by the inner handler or
// CORS middleware. Vary: * already implies Accept-Encoding, so it is left as-is.
func ensureVaryAcceptEncoding(h http.Header) {
existing := h.Get("Vary")
for part := range strings.SplitSeq(existing, ",") {
v := strings.TrimSpace(part)
if strings.EqualFold(v, "Accept-Encoding") || v == "*" {
return
}
}
if existing == "" {
h.Set("Vary", "Accept-Encoding")
} else {
h.Set("Vary", existing+", Accept-Encoding")
}
}

// hasUpgradeToken reports whether the Upgrade header contains token (RFC 7230
// §6.7 permits a comma-separated list; each token is matched case-insensitively).
func hasUpgradeToken(r *http.Request, token string) bool {
for _, field := range r.Header["Upgrade"] {
for part := range strings.SplitSeq(field, ",") {
if strings.EqualFold(strings.TrimSpace(part), token) {
return true
}
}
}
return false
}

// NewGzipHandler wraps next with gzip response compression. Compression is
// skipped for clients that do not advertise gzip support via Accept-Encoding.
// WebSocket upgrades are passed through unmodified; gzipResponseWriter also
// implements http.Hijacker as defense-in-depth for non-canonical Upgrade values.
func NewGzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Vary must be set on every response — compressed or not — so that CDN
// caches key on Accept-Encoding and never serve a wrong variant.
ensureVaryAcceptEncoding(w.Header())

if !acceptsGzip(r) || hasUpgradeToken(r, "websocket") {
next.ServeHTTP(w, r)
return
}

wrapper := &gzipResponseWriter{resp: w}
defer wrapper.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streaming coalescing: with no Content-Length, Close() only runs here at the end, so an incremental/streaming route's output buffers until completion — confirm no non-WS streaming RPC/REST route exists, or exempt it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, no non-WS streaming routes exist. Every handler in this server (http_json_handler.go, http_uri_handler.go) marshals the full response into a byte slice and calls w.Write() once — no route writes incrementally or calls Flush() mid-response.

Also worth noting: gzipResponseWriter.Flush() already propagates gz.Flush() → the underlying http.Flusher, so any future streaming route that calls Flush() periodically would get correct incremental delivery without needing any changes here.


next.ServeHTTP(wrapper, r)
Comment thread
cursor[bot] marked this conversation as resolved.
})
}
Loading
Loading