diff --git a/README.md b/README.md
index e498143..70a508a 100755
--- a/README.md
+++ b/README.md
@@ -88,6 +88,8 @@ For full documentation, visit https://docs.coldbrew.cloud
- [func AddWorkerRunOptions\(opts ...workers.RunOption\)](<#AddWorkerRunOptions>)
- [func InitializeVTProto\(\)](<#InitializeVTProto>)
- [func OTELMeterProvider\(\) otelmetric.MeterProvider](<#OTELMeterProvider>)
+- [func RegisterHTTPMarshaler\(mime string, m runtime.Marshaler\)](<#RegisterHTTPMarshaler>)
+- [func RegisterServeMuxOption\(opt runtime.ServeMuxOption\)](<#RegisterServeMuxOption>)
- [func SetOTELGRPCClientOptions\(opts ...otelgrpc.Option\)](<#SetOTELGRPCClientOptions>)
- [func SetOTELGRPCServerOptions\(opts ...otelgrpc.Option\)](<#SetOTELGRPCServerOptions>)
- [func SetOTELOptions\(opts grpcotel.Options\)](<#SetOTELOptions>)
@@ -153,8 +155,35 @@ func OTELMeterProvider() otelmetric.MeterProvider
OTELMeterProvider returns the global OTel MeterProvider. This is a convenience accessor for code that needs the interface type.
+
+## func [RegisterHTTPMarshaler]()
+
+```go
+func RegisterHTTPMarshaler(mime string, m runtime.Marshaler)
+```
+
+RegisterHTTPMarshaler registers a runtime.Marshaler for the given MIME type on the HTTP gateway. Equivalent to RegisterServeMuxOption\(runtime.WithMarshalerOption\(mime, m\)\).
+
+To override the gateway's default fallback for unregistered Content\-Types \(which is protojson via runtime.JSONPb\), register for runtime.MIMEWildcard.
+
+Must be called before core.Run\(\). Not safe for concurrent registration.
+
+
+## func [RegisterServeMuxOption]()
+
+```go
+func RegisterServeMuxOption(opt runtime.ServeMuxOption)
+```
+
+RegisterServeMuxOption appends a runtime.ServeMuxOption that initHTTP passes to runtime.NewServeMux. Registered options are applied AFTER core's built\-ins \(the incoming\-header matcher derived from HTTPHeaderPrefixes, the application/proto and application/protobuf marshalers, and the span\-route middleware\), so:
+
+- Last\-write\-wins options — WithMarshalerOption for a given MIME, WithErrorHandler, WithRoutingErrorHandler, WithIncomingHeaderMatcher — can intentionally override core's defaults. Overriding the incoming header matcher disables the HTTPHeaderPrefixes wiring; reimplement it yourself if you still need that behavior.
+- Additive options — WithMiddlewares, WithMetadata, WithForwardResponseOption — stack with core's.
+
+Must be called before core.Run\(\) \(typically from a service's PreStart hook\). Not safe for concurrent registration.
+
-## func [SetOTELGRPCClientOptions]()
+## func [SetOTELGRPCClientOptions]()
```go
func SetOTELGRPCClientOptions(opts ...otelgrpc.Option)
@@ -163,7 +192,7 @@ func SetOTELGRPCClientOptions(opts ...otelgrpc.Option)
Deprecated: Use SetOTELOptions instead. Only applies when OTEL\_USE\_LEGACY\_INSTRUMENTATION=true.
-## func [SetOTELGRPCServerOptions]()
+## func [SetOTELGRPCServerOptions]()
```go
func SetOTELGRPCServerOptions(opts ...otelgrpc.Option)
@@ -172,7 +201,7 @@ func SetOTELGRPCServerOptions(opts ...otelgrpc.Option)
Deprecated: Use SetOTELOptions instead. Only applies when OTEL\_USE\_LEGACY\_INSTRUMENTATION=true.
-## func [SetOTELOptions]()
+## func [SetOTELOptions]()
```go
func SetOTELOptions(opts grpcotel.Options)
@@ -331,7 +360,7 @@ type CB interface {
```
-### func [New]()
+### func [New]()
```go
func New(c config.Config) CB
diff --git a/compression.go b/compression.go
new file mode 100644
index 0000000..7039ae2
--- /dev/null
+++ b/compression.go
@@ -0,0 +1,19 @@
+package core
+
+import (
+ "net/http"
+
+ "github.com/go-coldbrew/core/config"
+ "github.com/klauspost/compress/gzhttp"
+)
+
+// newHTTPCompressionWrapper builds the gzhttp wrapper used by initHTTP. It
+// negotiates gzip and (unless disabled) zstd from Accept-Encoding. Pulled out
+// so it can be tested without standing up the full gateway.
+func newHTTPCompressionWrapper(cfg config.Config) (func(http.Handler) http.HandlerFunc, error) {
+ return gzhttp.NewWrapper(
+ gzhttp.MinSize(cfg.HTTPCompressionMinSize),
+ gzhttp.EnableZstd(!cfg.DisableZstdCompression),
+ gzhttp.PreferZstd(!cfg.DisableZstdCompression && cfg.PreferZstd),
+ )
+}
diff --git a/compression_test.go b/compression_test.go
new file mode 100644
index 0000000..5b07c9a
--- /dev/null
+++ b/compression_test.go
@@ -0,0 +1,101 @@
+package core
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-coldbrew/core/config"
+)
+
+func TestNewHTTPCompressionWrapper_NegotiatesEncoding(t *testing.T) {
+ body := strings.Repeat("payload-", 256) // ~2KiB, well above the 256-byte default
+
+ cases := []struct {
+ name string
+ cfg config.Config
+ acceptEncoding string
+ wantEncoding string
+ }{
+ {
+ name: "zstd-preferred-default",
+ cfg: config.Config{HTTPCompressionMinSize: 256, PreferZstd: true},
+ acceptEncoding: "gzip, zstd",
+ wantEncoding: "zstd",
+ },
+ {
+ name: "client-only-gzip",
+ cfg: config.Config{HTTPCompressionMinSize: 256, PreferZstd: true},
+ acceptEncoding: "gzip",
+ wantEncoding: "gzip",
+ },
+ {
+ name: "zstd-disabled-falls-back-to-gzip",
+ cfg: config.Config{HTTPCompressionMinSize: 256, DisableZstdCompression: true, PreferZstd: true},
+ acceptEncoding: "gzip, zstd",
+ wantEncoding: "gzip",
+ },
+ {
+ name: "no-accept-encoding-no-compression",
+ cfg: config.Config{HTTPCompressionMinSize: 256, PreferZstd: true},
+ acceptEncoding: "",
+ wantEncoding: "",
+ },
+ {
+ name: "prefer-zstd-false-picks-gzip-when-equal-q",
+ cfg: config.Config{HTTPCompressionMinSize: 256, PreferZstd: false},
+ acceptEncoding: "gzip, zstd",
+ wantEncoding: "gzip",
+ },
+ }
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.Write([]byte(body))
+ })
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ wrapper, err := newHTTPCompressionWrapper(tc.cfg)
+ if err != nil {
+ t.Fatalf("newHTTPCompressionWrapper: %v", err)
+ }
+ wrapped := wrapper(handler)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ if tc.acceptEncoding != "" {
+ req.Header.Set("Accept-Encoding", tc.acceptEncoding)
+ }
+ rec := httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, req)
+
+ got := rec.Header().Get("Content-Encoding")
+ if got != tc.wantEncoding {
+ t.Fatalf("Content-Encoding = %q, want %q", got, tc.wantEncoding)
+ }
+ })
+ }
+}
+
+func TestNewHTTPCompressionWrapper_BelowMinSize(t *testing.T) {
+ cfg := config.Config{HTTPCompressionMinSize: 256, PreferZstd: true}
+ wrapper, err := newHTTPCompressionWrapper(cfg)
+ if err != nil {
+ t.Fatalf("newHTTPCompressionWrapper: %v", err)
+ }
+
+ wrapped := wrapper(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.Write([]byte("tiny"))
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Accept-Encoding", "gzip, zstd")
+ rec := httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, req)
+
+ if got := rec.Header().Get("Content-Encoding"); got != "" {
+ t.Fatalf("Content-Encoding = %q, want empty (body below MinSize)", got)
+ }
+}
diff --git a/config/README.md b/config/README.md
index 366c290..ece5eaf 100755
--- a/config/README.md
+++ b/config/README.md
@@ -67,7 +67,7 @@ import "github.com/go-coldbrew/core/config"
-## type [Config]()
+## type [Config]()
Config is the configuration for the Coldbrew server It is populated from environment variables and has sensible defaults for all fields so that you can just use it as is without any configuration The following environment variables are supported and can be used to override the defaults for the fields
@@ -248,6 +248,13 @@ type Config struct {
// HTTPCompressionMinSize is the minimum response body size (bytes) before compression is applied.
// Responses smaller than this are sent uncompressed. Applies to both gzip and zstd.
HTTPCompressionMinSize int `envconfig:"HTTP_COMPRESSION_MIN_SIZE" env:"HTTP_COMPRESSION_MIN_SIZE" default:"256"`
+ // DisableZstdCompression disables zstd compression on the HTTP gateway. When false
+ // (default), zstd is offered alongside gzip and selected via Accept-Encoding
+ // negotiation. Has no effect if DisableHTTPCompression is true.
+ DisableZstdCompression bool `envconfig:"DISABLE_ZSTD_COMPRESSION" env:"DISABLE_ZSTD_COMPRESSION" default:"false"`
+ // PreferZstd causes the HTTP gateway to prefer zstd over gzip when a client
+ // advertises both in Accept-Encoding. Default true. Ignored if zstd is disabled.
+ PreferZstd bool `envconfig:"PREFER_ZSTD" env:"PREFER_ZSTD" default:"true"`
// ResponseTimeLogLevel sets the log level for per-request response time logging.
// Valid values: "debug", "info", "warn", "error". Invalid values default to "info".
ResponseTimeLogLevel string `envconfig:"RESPONSE_TIME_LOG_LEVEL" env:"RESPONSE_TIME_LOG_LEVEL" default:"info"`
@@ -262,7 +269,7 @@ type Config struct {
```
-### func \(Config\) [Validate]()
+### func \(Config\) [Validate]()
```go
func (c Config) Validate() []string
@@ -271,7 +278,7 @@ func (c Config) Validate() []string
Validate checks the configuration for common misconfigurations and returns a list of warning messages. It does not return an error to avoid breaking existing services — warnings are meant to be logged at startup.
-### func \(Config\) [ValidateStrict]()
+### func \(Config\) [ValidateStrict]()
```go
func (c Config) ValidateStrict() []error
diff --git a/config/config.go b/config/config.go
index 2876eba..3828579 100644
--- a/config/config.go
+++ b/config/config.go
@@ -191,6 +191,13 @@ type Config struct {
// HTTPCompressionMinSize is the minimum response body size (bytes) before compression is applied.
// Responses smaller than this are sent uncompressed. Applies to both gzip and zstd.
HTTPCompressionMinSize int `envconfig:"HTTP_COMPRESSION_MIN_SIZE" env:"HTTP_COMPRESSION_MIN_SIZE" default:"256"`
+ // DisableZstdCompression disables zstd compression on the HTTP gateway. When false
+ // (default), zstd is offered alongside gzip and selected via Accept-Encoding
+ // negotiation. Has no effect if DisableHTTPCompression is true.
+ DisableZstdCompression bool `envconfig:"DISABLE_ZSTD_COMPRESSION" env:"DISABLE_ZSTD_COMPRESSION" default:"false"`
+ // PreferZstd causes the HTTP gateway to prefer zstd over gzip when a client
+ // advertises both in Accept-Encoding. Default true. Ignored if zstd is disabled.
+ PreferZstd bool `envconfig:"PREFER_ZSTD" env:"PREFER_ZSTD" default:"true"`
// ResponseTimeLogLevel sets the log level for per-request response time logging.
// Valid values: "debug", "info", "warn", "error". Invalid values default to "info".
ResponseTimeLogLevel string `envconfig:"RESPONSE_TIME_LOG_LEVEL" env:"RESPONSE_TIME_LOG_LEVEL" default:"info"`
diff --git a/core.go b/core.go
index a6f5610..65c18d4 100644
--- a/core.go
+++ b/core.go
@@ -26,7 +26,6 @@ import (
"github.com/go-coldbrew/workers"
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
- "github.com/klauspost/compress/gzhttp"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.opentelemetry.io/otel"
@@ -469,6 +468,8 @@ func (c *cb) initHTTP(ctx context.Context) (*http.Server, error) {
)
}
+ muxOpts = append(muxOpts, registeredServeMuxOptions()...)
+
mux := runtime.NewServeMux(muxOpts...)
creds := c.creds
@@ -522,7 +523,7 @@ func (c *cb) initHTTP(ctx context.Context) (*http.Server, error) {
promHandler := promhttp.Handler()
gzipHandler := http.Handler(tracingWrapper(mux))
if !c.config.DisableHTTPCompression {
- wrapper, err := gzhttp.NewWrapper(gzhttp.MinSize(c.config.HTTPCompressionMinSize))
+ wrapper, err := newHTTPCompressionWrapper(c.config)
if err != nil {
return nil, fmt.Errorf("failed to create compression handler: %w", err)
}
diff --git a/serve_mux_options.go b/serve_mux_options.go
new file mode 100644
index 0000000..b975c56
--- /dev/null
+++ b/serve_mux_options.go
@@ -0,0 +1,48 @@
+package core
+
+import "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+
+// httpServeMuxOptions accumulates options registered via
+// RegisterServeMuxOption / RegisterHTTPMarshaler. Init-only — not safe for
+// concurrent registration. Matches the existing config-function pattern in
+// the interceptors package; the alternative (mutex/atomic) would diverge
+// from the rest of the codebase for no real benefit at startup.
+var httpServeMuxOptions []runtime.ServeMuxOption
+
+// RegisterServeMuxOption appends a runtime.ServeMuxOption that initHTTP
+// passes to runtime.NewServeMux. Registered options are applied AFTER core's
+// built-ins (the incoming-header matcher derived from HTTPHeaderPrefixes,
+// the application/proto and application/protobuf marshalers, and the
+// span-route middleware), so:
+//
+// - Last-write-wins options — WithMarshalerOption for a given MIME,
+// WithErrorHandler, WithRoutingErrorHandler, WithIncomingHeaderMatcher —
+// can intentionally override core's defaults. Overriding the incoming
+// header matcher disables the HTTPHeaderPrefixes wiring; reimplement it
+// yourself if you still need that behavior.
+// - Additive options — WithMiddlewares, WithMetadata,
+// WithForwardResponseOption — stack with core's.
+//
+// Must be called before core.Run() (typically from a service's PreStart
+// hook). Not safe for concurrent registration.
+func RegisterServeMuxOption(opt runtime.ServeMuxOption) {
+ httpServeMuxOptions = append(httpServeMuxOptions, opt)
+}
+
+// RegisterHTTPMarshaler registers a runtime.Marshaler for the given MIME
+// type on the HTTP gateway. Equivalent to
+// RegisterServeMuxOption(runtime.WithMarshalerOption(mime, m)).
+//
+// To override the gateway's default fallback for unregistered Content-Types
+// (which is protojson via runtime.JSONPb), register for runtime.MIMEWildcard.
+//
+// Must be called before core.Run(). Not safe for concurrent registration.
+func RegisterHTTPMarshaler(mime string, m runtime.Marshaler) {
+ RegisterServeMuxOption(runtime.WithMarshalerOption(mime, m))
+}
+
+// registeredServeMuxOptions returns a defensive copy so callers (initHTTP,
+// tests) can append to the result without mutating the package-level slice.
+func registeredServeMuxOptions() []runtime.ServeMuxOption {
+ return append([]runtime.ServeMuxOption(nil), httpServeMuxOptions...)
+}
diff --git a/serve_mux_options_test.go b/serve_mux_options_test.go
new file mode 100644
index 0000000..6018f79
--- /dev/null
+++ b/serve_mux_options_test.go
@@ -0,0 +1,129 @@
+package core
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+)
+
+type fakeMarshaler struct {
+ contentType string
+}
+
+func (f *fakeMarshaler) Marshal(v any) ([]byte, error) { return []byte("fake"), nil }
+func (f *fakeMarshaler) Unmarshal(_ []byte, _ any) error { return errors.New("not used") }
+func (f *fakeMarshaler) NewDecoder(_ io.Reader) runtime.Decoder {
+ return runtime.DecoderFunc(func(_ any) error { return errors.New("not used") })
+}
+
+func (f *fakeMarshaler) NewEncoder(_ io.Writer) runtime.Encoder {
+ return runtime.EncoderFunc(func(_ any) error { return errors.New("not used") })
+}
+func (f *fakeMarshaler) ContentType(_ any) string { return f.contentType }
+
+func resetServeMuxOptionsForTest() {
+ httpServeMuxOptions = nil
+}
+
+func TestRegisterHTTPMarshaler_RoundTrip(t *testing.T) {
+ resetServeMuxOptionsForTest()
+ t.Cleanup(resetServeMuxOptionsForTest)
+
+ want := &fakeMarshaler{contentType: "application/x-test"}
+ RegisterHTTPMarshaler("application/x-test", want)
+
+ mux := runtime.NewServeMux(registeredServeMuxOptions()...)
+
+ req := httptest.NewRequest(http.MethodGet, "/anything", nil)
+ req.Header.Set("Accept", "application/x-test")
+ req.Header.Set("Content-Type", "application/x-test")
+
+ _, outbound := runtime.MarshalerForRequest(mux, req)
+ if outbound != want {
+ t.Fatalf("MarshalerForRequest returned %T, want the registered fake marshaler", outbound)
+ }
+}
+
+func TestRegisterServeMuxOption_MiddlewareStacks(t *testing.T) {
+ resetServeMuxOptionsForTest()
+ t.Cleanup(resetServeMuxOptionsForTest)
+
+ var customCalled, builtinCalled bool
+
+ customMiddleware := func(next runtime.HandlerFunc) runtime.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request, p map[string]string) {
+ customCalled = true
+ next(w, r, p)
+ }
+ }
+ builtinMiddleware := func(next runtime.HandlerFunc) runtime.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request, p map[string]string) {
+ builtinCalled = true
+ next(w, r, p)
+ }
+ }
+
+ RegisterServeMuxOption(runtime.WithMiddlewares(customMiddleware))
+
+ muxOpts := []runtime.ServeMuxOption{runtime.WithMiddlewares(builtinMiddleware)}
+ muxOpts = append(muxOpts, registeredServeMuxOptions()...)
+ mux := runtime.NewServeMux(muxOpts...)
+
+ if err := mux.HandlePath(http.MethodGet, "/probe", func(w http.ResponseWriter, _ *http.Request, _ map[string]string) {
+ w.WriteHeader(http.StatusOK)
+ }); err != nil {
+ t.Fatalf("HandlePath: %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/probe", nil))
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !customCalled {
+ t.Error("custom middleware was not invoked")
+ }
+ if !builtinCalled {
+ t.Error("built-in middleware was not invoked — registered options must stack, not replace")
+ }
+}
+
+func TestRegisterHTTPMarshaler_Override(t *testing.T) {
+ resetServeMuxOptionsForTest()
+ t.Cleanup(resetServeMuxOptionsForTest)
+
+ custom := &fakeMarshaler{contentType: "application/json"}
+ RegisterHTTPMarshaler("application/json", custom)
+
+ muxOpts := []runtime.ServeMuxOption{
+ runtime.WithMarshalerOption("application/json", &runtime.JSONBuiltin{}),
+ }
+ muxOpts = append(muxOpts, registeredServeMuxOptions()...)
+ mux := runtime.NewServeMux(muxOpts...)
+
+ req := httptest.NewRequest(http.MethodGet, "/anything", nil)
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+
+ _, outbound := runtime.MarshalerForRequest(mux, req)
+ if outbound != custom {
+ t.Fatalf("MarshalerForRequest returned %T, want the user-registered marshaler (last-write-wins)", outbound)
+ }
+}
+
+func TestResetServeMuxOptionsForTest(t *testing.T) {
+ resetServeMuxOptionsForTest()
+ RegisterServeMuxOption(runtime.WithMarshalerOption("application/x-foo", &fakeMarshaler{}))
+ if got := len(registeredServeMuxOptions()); got != 1 {
+ t.Fatalf("after register: got %d options, want 1", got)
+ }
+ resetServeMuxOptionsForTest()
+ if got := len(registeredServeMuxOptions()); got != 0 {
+ t.Fatalf("after reset: got %d options, want 0", got)
+ }
+}