feat: add Brotli compression support and related tests

Kim committed Apr 6, 2026 at 18:55 UTC 1833a14f5a4de8021a6fc8c18232154fce0e1d1b
5 files changed +453 -101
go.mod
+1
@@ -4,6 +4,7 @@ go 1.26.1
4
5 require (
6 cloud.google.com/go/compute/metadata v0.9.0
7 + github.com/andybalholm/brotli v1.2.1
8 github.com/aws/aws-sdk-go-v2 v1.41.1
9 github.com/aws/aws-sdk-go-v2/config v1.32.8
10 github.com/aws/aws-sdk-go-v2/credentials v1.19.8
go.sum
+4
@@ -6,6 +6,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
6 cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
7 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
8 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
9 +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
10 +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
11 github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
12 github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
13 github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
@@ -101,6 +103,8 @@ github.com/spruceid/siwe-go v0.2.1 h1:BroySys6CyUzeyNppTseEOT/w56xTdOfcmECTI7rnu
103 github.com/spruceid/siwe-go v0.2.1/go.mod h1:MHpHbptGsM3lHth2L8quhZ9ipiwST8zsJH1CjWpeO1k=
104 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
105 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
106 +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
107 +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
108 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
109 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
110 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
sdk/expose.go
-101
@@ -472,104 +472,3 @@ func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr
472 }
473 return RunHTTP(ctx, relayListener, handler, localAddr)
474 }
475 -
476 -func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
477 - localAddr = strings.TrimSpace(localAddr)
478 -
479 - if relayListener == nil && localAddr == "" {
480 - return errors.New("relay listener or local address is required")
481 - }
482 -
483 - var relaySrv *http.Server
484 - if relayListener != nil {
485 - relaySrv = &http.Server{
486 - Handler: handler,
487 - ReadHeaderTimeout: defaultRequestTimeout,
488 - }
489 - }
490 -
491 - var localSrv *http.Server
492 - if localAddr != "" {
493 - localSrv = &http.Server{
494 - Addr: localAddr,
495 - Handler: handler,
496 - ReadHeaderTimeout: defaultRequestTimeout,
497 - }
498 - }
499 -
500 - serverCount := 0
501 - if relaySrv != nil {
502 - serverCount++
503 - }
504 - if localSrv != nil {
505 - serverCount++
506 - }
507 -
508 - results := make(chan error, serverCount)
509 - normalizeServeErr := func(err error, prefix string) error {
510 - if err == nil || errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
511 - return nil
512 - }
513 - return fmt.Errorf("%s: %w", prefix, err)
514 - }
515 -
516 - var (
517 - shutdownOnce sync.Once
518 - shutdownErr error
519 - )
520 - shutdown := func() error {
521 - shutdownOnce.Do(func() {
522 - shutdownCtx, cancel := context.WithTimeout(context.Background(), defaultHTTPShutdownTimeout)
523 - defer cancel()
524 -
525 - var localErr error
526 - if localSrv != nil {
527 - localErr = localSrv.Shutdown(shutdownCtx)
528 - if errors.Is(localErr, http.ErrServerClosed) {
529 - localErr = nil
530 - }
531 - }
532 -
533 - var relayErr error
534 - if relaySrv != nil {
535 - relayErr = relaySrv.Shutdown(shutdownCtx)
536 - if errors.Is(relayErr, http.ErrServerClosed) {
537 - relayErr = nil
538 - }
539 - }
540 -
541 - shutdownErr = errors.Join(localErr, relayErr)
542 - })
543 - return shutdownErr
544 - }
545 -
546 - if localSrv != nil {
547 - go func() {
548 - results <- normalizeServeErr(localSrv.ListenAndServe(), "serve local http")
549 - }()
550 - }
551 - if relaySrv != nil {
552 - go func() {
553 - results <- normalizeServeErr(relaySrv.Serve(relayListener), "serve relay http")
554 - }()
555 - }
556 -
557 - var serveErr error
558 - remaining := serverCount
559 - ctxDone := ctx.Done()
560 - for remaining > 0 {
561 - select {
562 - case err := <-results:
563 - remaining--
564 - if err != nil {
565 - serveErr = errors.Join(serveErr, err)
566 - _ = shutdown()
567 - }
568 - case <-ctxDone:
569 - _ = shutdown()
570 - ctxDone = nil
571 - }
572 - }
573 -
574 - return errors.Join(serveErr, shutdownErr)
575 -}
sdk/http.go new
+322
@@ -0,0 +1,322 @@
1 +package sdk
2 +
3 +import (
4 + "bufio"
5 + "compress/gzip"
6 + "context"
7 + "errors"
8 + "fmt"
9 + "io"
10 + "net"
11 + "net/http"
12 + "strconv"
13 + "strings"
14 + "sync"
15 +
16 + "github.com/andybalholm/brotli"
17 +)
18 +
19 +func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
20 + if relayListener == nil && localAddr == "" {
21 + return errors.New("relay listener or local address is required")
22 + }
23 +
24 + serverHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 + serveCompressedHTTP(handler, w, r)
26 + })
27 +
28 + var relaySrv *http.Server
29 + if relayListener != nil {
30 + relaySrv = &http.Server{
31 + Handler: serverHandler,
32 + ReadHeaderTimeout: defaultRequestTimeout,
33 + }
34 + }
35 +
36 + var localSrv *http.Server
37 + if localAddr != "" {
38 + localSrv = &http.Server{
39 + Addr: localAddr,
40 + Handler: serverHandler,
41 + ReadHeaderTimeout: defaultRequestTimeout,
42 + }
43 + }
44 +
45 + serverCount := 0
46 + if relaySrv != nil {
47 + serverCount++
48 + }
49 + if localSrv != nil {
50 + serverCount++
51 + }
52 +
53 + results := make(chan error, serverCount)
54 + normalizeServeErr := func(err error, prefix string) error {
55 + if err == nil || errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
56 + return nil
57 + }
58 + return fmt.Errorf("%s: %w", prefix, err)
59 + }
60 +
61 + var (
62 + shutdownOnce sync.Once
63 + shutdownErr error
64 + )
65 + shutdown := func() error {
66 + shutdownOnce.Do(func() {
67 + shutdownCtx, cancel := context.WithTimeout(context.Background(), defaultHTTPShutdownTimeout)
68 + defer cancel()
69 +
70 + var localErr error
71 + if localSrv != nil {
72 + localErr = localSrv.Shutdown(shutdownCtx)
73 + if errors.Is(localErr, http.ErrServerClosed) {
74 + localErr = nil
75 + }
76 + }
77 +
78 + var relayErr error
79 + if relaySrv != nil {
80 + relayErr = relaySrv.Shutdown(shutdownCtx)
81 + if errors.Is(relayErr, http.ErrServerClosed) {
82 + relayErr = nil
83 + }
84 + }
85 +
86 + shutdownErr = errors.Join(localErr, relayErr)
87 + })
88 + return shutdownErr
89 + }
90 +
91 + if localSrv != nil {
92 + go func() {
93 + results <- normalizeServeErr(localSrv.ListenAndServe(), "serve local http")
94 + }()
95 + }
96 + if relaySrv != nil {
97 + go func() {
98 + results <- normalizeServeErr(relaySrv.Serve(relayListener), "serve relay http")
99 + }()
100 + }
101 +
102 + var serveErr error
103 + remaining := serverCount
104 + ctxDone := ctx.Done()
105 + for remaining > 0 {
106 + select {
107 + case err := <-results:
108 + remaining--
109 + if err != nil {
110 + serveErr = errors.Join(serveErr, err)
111 + _ = shutdown()
112 + }
113 + case <-ctxDone:
114 + _ = shutdown()
115 + ctxDone = nil
116 + }
117 + }
118 +
119 + return errors.Join(serveErr, shutdownErr)
120 +}
121 +
122 +func serveCompressedHTTP(handler http.Handler, w http.ResponseWriter, r *http.Request) {
123 + if handler == nil {
124 + http.NotFound(w, r)
125 + return
126 + }
127 +
128 + format := ""
129 + if r != nil {
130 + parseQuality := func(params string) float64 {
131 + for param := range strings.SplitSeq(params, ";") {
132 + key, value, ok := strings.Cut(strings.TrimSpace(param), "=")
133 + if !ok || !strings.EqualFold(strings.TrimSpace(key), "q") {
134 + continue
135 + }
136 +
137 + q, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
138 + if err != nil || q < 0 {
139 + return 0
140 + }
141 + if q > 1 {
142 + return 1
143 + }
144 + return q
145 + }
146 + return 1
147 + }
148 +
149 + bestQ := 0.0
150 + for rawPart := range strings.SplitSeq(r.Header.Get("Accept-Encoding"), ",") {
151 + part := strings.TrimSpace(strings.ToLower(rawPart))
152 + if part == "" {
153 + continue
154 + }
155 +
156 + name, params, _ := strings.Cut(part, ";")
157 + candidate := strings.TrimSpace(name)
158 + if candidate != "br" && candidate != "gzip" {
159 + continue
160 + }
161 +
162 + q := parseQuality(params)
163 + if q <= 0 {
164 + continue
165 + }
166 +
167 + if q > bestQ || (q == bestQ && candidate == "br") {
168 + format = candidate
169 + bestQ = q
170 + }
171 + }
172 + }
173 + if format == "" || strings.TrimSpace(r.Header.Get("Range")) != "" {
174 + handler.ServeHTTP(w, r)
175 + return
176 + }
177 + if headerContainsToken(r.Header.Values("Connection"), "upgrade") && strings.TrimSpace(r.Header.Get("Upgrade")) != "" {
178 + handler.ServeHTTP(w, r)
179 + return
180 + }
181 +
182 + writer := &compressedResponseWriter{
183 + ResponseWriter: w,
184 + format: format,
185 + }
186 + defer func() {
187 + _ = writer.Close()
188 + }()
189 +
190 + handler.ServeHTTP(writer, r)
191 +}
192 +
193 +func headerContainsToken(values []string, target string) bool {
194 + target = strings.ToLower(strings.TrimSpace(target))
195 + for _, value := range values {
196 + for _, part := range strings.Split(value, ",") {
197 + if strings.ToLower(strings.TrimSpace(part)) == target {
198 + return true
199 + }
200 + }
201 + }
202 + return false
203 +}
204 +
205 +type compressedResponseWriter struct {
206 + http.ResponseWriter
207 + format string
208 + writer io.WriteCloser
209 + flushWriter func() error
210 + wroteHeader bool
211 + passthrough bool
212 +}
213 +
214 +func (w *compressedResponseWriter) WriteHeader(statusCode int) {
215 + if w.wroteHeader {
216 + return
217 + }
218 + w.wroteHeader = true
219 +
220 + header := w.Header()
221 + contentType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(header.Get("Content-Type"))), ";")
222 + contentType = strings.TrimSpace(contentType)
223 + compressible := strings.HasPrefix(contentType, "text/")
224 + switch contentType {
225 + case "application/json", "application/javascript", "application/xml", "image/svg+xml":
226 + compressible = true
227 + }
228 + smallResponse := false
229 + if contentLength := strings.TrimSpace(header.Get("Content-Length")); contentLength != "" {
230 + if n, err := strconv.ParseInt(contentLength, 10, 64); err == nil && n >= 0 && n <= 1024 {
231 + smallResponse = true
232 + }
233 + }
234 + switch {
235 + case statusCode >= 100 && statusCode < 200:
236 + w.passthrough = true
237 + case statusCode == http.StatusNoContent || statusCode == http.StatusNotModified:
238 + w.passthrough = true
239 + case !compressible:
240 + w.passthrough = true
241 + case smallResponse:
242 + w.passthrough = true
243 + case strings.TrimSpace(header.Get("Content-Encoding")) != "":
244 + w.passthrough = true
245 + case strings.TrimSpace(header.Get("Content-Range")) != "":
246 + w.passthrough = true
247 + case strings.HasPrefix(contentType, "text/event-stream"):
248 + w.passthrough = true
249 + case headerContainsToken(header.Values("Cache-Control"), "no-transform"):
250 + w.passthrough = true
251 + }
252 + if w.passthrough {
253 + w.ResponseWriter.WriteHeader(statusCode)
254 + return
255 + }
256 +
257 + switch w.format {
258 + case "br":
259 + writer := brotli.NewWriter(w.ResponseWriter)
260 + w.writer = writer
261 + w.flushWriter = writer.Flush
262 + case "gzip":
263 + writer := gzip.NewWriter(w.ResponseWriter)
264 + w.writer = writer
265 + w.flushWriter = writer.Flush
266 + default:
267 + w.passthrough = true
268 + w.ResponseWriter.WriteHeader(statusCode)
269 + return
270 + }
271 +
272 + header.Del("Content-Length")
273 + header.Set("Content-Encoding", w.format)
274 + if !headerContainsToken(header.Values("Vary"), "accept-encoding") {
275 + header.Add("Vary", "Accept-Encoding")
276 + }
277 + w.ResponseWriter.WriteHeader(statusCode)
278 +}
279 +
280 +func (w *compressedResponseWriter) Write(p []byte) (int, error) {
281 + if !w.wroteHeader {
282 + header := w.Header()
283 + if strings.TrimSpace(header.Get("Content-Type")) == "" && len(p) > 0 {
284 + header.Set("Content-Type", http.DetectContentType(p))
285 + }
286 + w.WriteHeader(http.StatusOK)
287 + }
288 + if w.passthrough {
289 + return w.ResponseWriter.Write(p)
290 + }
291 + return w.writer.Write(p)
292 +}
293 +
294 +func (w *compressedResponseWriter) Flush() {
295 + if !w.wroteHeader {
296 + w.WriteHeader(http.StatusOK)
297 + }
298 + if !w.passthrough && w.flushWriter != nil {
299 + _ = w.flushWriter()
300 + }
301 + if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
302 + flusher.Flush()
303 + }
304 +}
305 +
306 +func (w *compressedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
307 + hijacker, ok := w.ResponseWriter.(http.Hijacker)
308 + if !ok {
309 + return nil, nil, http.ErrNotSupported
310 + }
311 + return hijacker.Hijack()
312 +}
313 +
314 +func (w *compressedResponseWriter) Close() error {
315 + if w.writer == nil {
316 + return nil
317 + }
318 + err := w.writer.Close()
319 + w.writer = nil
320 + w.flushWriter = nil
321 + return err
322 +}
sdk/http_test.go new
+126
@@ -0,0 +1,126 @@
1 +package sdk
2 +
3 +import (
4 + "net/http"
5 + "net/http/httptest"
6 + "testing"
7 +)
8 +
9 +func TestServeCompressedHTTPChoosesAcceptedEncoding(t *testing.T) {
10 + t.Parallel()
11 +
12 + tests := []struct {
13 + name string
14 + acceptEncoding string
15 + want string
16 + }{
17 + {name: "missing header", want: ""},
18 + {name: "unsupported encoding only", acceptEncoding: "deflate", want: ""},
19 + {name: "gzip accepted", acceptEncoding: "gzip", want: "gzip"},
20 + {name: "brotli preferred on tie", acceptEncoding: "gzip, br", want: "br"},
21 + {name: "quality chooses gzip", acceptEncoding: "gzip;q=1, br;q=0.5", want: "gzip"},
22 + {name: "zero quality disables format", acceptEncoding: "gzip;q=0, br;q=0", want: ""},
23 + {name: "wildcard ignored", acceptEncoding: "*", want: ""},
24 + }
25 +
26 + for _, tt := range tests {
27 + t.Run(tt.name, func(t *testing.T) {
28 + t.Parallel()
29 +
30 + req := httptest.NewRequest("GET", "/", nil)
31 + if tt.acceptEncoding != "" {
32 + req.Header.Set("Accept-Encoding", tt.acceptEncoding)
33 + }
34 + rec := httptest.NewRecorder()
35 +
36 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37 + w.Header().Set("Content-Type", "text/plain; charset=utf-8")
38 + w.Header().Set("Content-Length", "2048")
39 + _, _ = w.Write([]byte("hello world"))
40 + })
41 +
42 + serveCompressedHTTP(handler, rec, req)
43 +
44 + if got := rec.Header().Get("Content-Encoding"); got != tt.want {
45 + t.Fatalf("Content-Encoding = %q, want %q", got, tt.want)
46 + }
47 + })
48 + }
49 +}
50 +
51 +func TestServeCompressedHTTPCompressesTextResponses(t *testing.T) {
52 + t.Parallel()
53 +
54 + req := httptest.NewRequest("GET", "/", nil)
55 + req.Header.Set("Accept-Encoding", "gzip")
56 + rec := httptest.NewRecorder()
57 +
58 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59 + w.Header().Set("Content-Type", "text/plain; charset=utf-8")
60 + _, _ = w.Write([]byte("hello world"))
61 + })
62 +
63 + serveCompressedHTTP(handler, rec, req)
64 +
65 + if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
66 + t.Fatalf("Content-Encoding = %q, want gzip", got)
67 + }
68 +}
69 +
70 +func TestServeCompressedHTTPBypassesBinaryResponses(t *testing.T) {
71 + t.Parallel()
72 +
73 + req := httptest.NewRequest("GET", "/", nil)
74 + req.Header.Set("Accept-Encoding", "gzip")
75 + rec := httptest.NewRecorder()
76 +
77 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78 + w.Header().Set("Content-Type", "image/png")
79 + _, _ = w.Write([]byte("not-really-a-png"))
80 + })
81 +
82 + serveCompressedHTTP(handler, rec, req)
83 +
84 + if got := rec.Header().Get("Content-Encoding"); got != "" {
85 + t.Fatalf("Content-Encoding = %q, want empty", got)
86 + }
87 +}
88 +
89 +func TestServeCompressedHTTPBypassesSmallResponsesWithContentLength(t *testing.T) {
90 + t.Parallel()
91 +
92 + req := httptest.NewRequest("GET", "/", nil)
93 + req.Header.Set("Accept-Encoding", "gzip")
94 + rec := httptest.NewRecorder()
95 +
96 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97 + w.Header().Set("Content-Type", "application/json")
98 + w.Header().Set("Content-Length", "12")
99 + _, _ = w.Write([]byte(`{"ok":true}`))
100 + })
101 +
102 + serveCompressedHTTP(handler, rec, req)
103 +
104 + if got := rec.Header().Get("Content-Encoding"); got != "" {
105 + t.Fatalf("Content-Encoding = %q, want empty", got)
106 + }
107 +}
108 +
109 +func TestServeCompressedHTTPIgnoresSmallThresholdWithoutContentLength(t *testing.T) {
110 + t.Parallel()
111 +
112 + req := httptest.NewRequest("GET", "/", nil)
113 + req.Header.Set("Accept-Encoding", "gzip")
114 + rec := httptest.NewRecorder()
115 +
116 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117 + w.Header().Set("Content-Type", "application/json")
118 + _, _ = w.Write([]byte(`{"ok":true}`))
119 + })
120 +
121 + serveCompressedHTTP(handler, rec, req)
122 +
123 + if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
124 + t.Fatalf("Content-Encoding = %q, want gzip", got)
125 + }
126 +}