ef24215bde
For every dynamic response above the 1 KiB compression threshold the
builder previously did one of:
brotli.NewWriterV2(b.w, brotli.DefaultCompression)
gzip.NewWriter(b.w)
flate.NewWriter(b.w, -1)
Each constructor allocates the encoder's working set from scratch:
brotli's sliding window + hash tables (~3.5 MiB at quality 6), gzip's
CRC32 + deflate state (~800 KiB), flate's hash chains (~800 KiB).
On a busy server that's per-request churn the GC then has to clean up.
All three writer types expose Reset(dst io.Writer), which rebinds the
destination without touching the internal buffers. So put each behind a
sync.Pool and Get/Reset/Put around the existing Write+Close pair. The
constructor signatures didn't change; only Pool.Get + Reset are new.
Note that brotli.NewWriterV2 (kept from before this change) returns
*matchfinder.Writer, not *brotli.Writer, as V2 is the pure-Go encoder
built on top of github.com/andybalholm/brotli/matchfinder, where the
actual Writer type lives. Hence the matchfinder import.
On a local artificial benchmarks of a 130 KiB HTML-like payload consisting of
250 entry-list items, on a single-core it improves performances by around 10%,
and for multicore under GC pressure, ns/op is reduces by ~80% and B/op by ~99%.
243 lines
6.2 KiB
Go
243 lines
6.2 KiB
Go
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package response // import "miniflux.app/v2/internal/http/response"
|
|
|
|
import (
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"mime"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/andybalholm/brotli/matchfinder"
|
|
)
|
|
|
|
const compressionThreshold = 1024
|
|
|
|
// Compression writers are pooled so each request reuses their internal
|
|
// state (brotli sliding window + hash tables, flate dictionary, etc.)
|
|
// instead of allocating it from scratch. Reset(dst) rebinds the
|
|
// destination without re-allocating the buffers.
|
|
var (
|
|
brotliWriterPool = sync.Pool{
|
|
New: func() any {
|
|
return brotli.NewWriterV2(io.Discard, brotli.DefaultCompression)
|
|
},
|
|
}
|
|
gzipWriterPool = sync.Pool{
|
|
New: func() any {
|
|
return gzip.NewWriter(io.Discard)
|
|
},
|
|
}
|
|
flateWriterPool = sync.Pool{
|
|
New: func() any {
|
|
w, _ := flate.NewWriter(io.Discard, flate.DefaultCompression)
|
|
return w
|
|
},
|
|
}
|
|
)
|
|
|
|
// Builder generates HTTP responses.
|
|
type Builder struct {
|
|
w http.ResponseWriter
|
|
r *http.Request
|
|
statusCode int
|
|
headers http.Header
|
|
enableCompression bool
|
|
body any
|
|
}
|
|
|
|
// NewBuilder creates a new response builder.
|
|
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
|
|
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
|
|
}
|
|
|
|
// WithStatus uses the given status code to build the response.
|
|
func (b *Builder) WithStatus(statusCode int) *Builder {
|
|
b.statusCode = statusCode
|
|
return b
|
|
}
|
|
|
|
// WithHeader adds the given HTTP header to the response.
|
|
func (b *Builder) WithHeader(key, value string) *Builder {
|
|
b.headers.Set(key, value)
|
|
return b
|
|
}
|
|
|
|
// WithBodyAsBytes uses the given bytes to build the response.
|
|
func (b *Builder) WithBodyAsBytes(body []byte) *Builder {
|
|
b.body = body
|
|
return b
|
|
}
|
|
|
|
// WithBodyAsString uses the given string to build the response.
|
|
func (b *Builder) WithBodyAsString(body string) *Builder {
|
|
b.body = body
|
|
return b
|
|
}
|
|
|
|
// WithBodyAsReader uses the given reader to build the response.
|
|
func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
|
|
b.body = body
|
|
return b
|
|
}
|
|
|
|
// WithAttachment forces the document to be downloaded by the web browser.
|
|
func (b *Builder) WithAttachment(filename string) *Builder {
|
|
b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
|
|
return b
|
|
}
|
|
|
|
// WithInline suggests an inline filename for the current response.
|
|
func (b *Builder) WithInline(filename string) *Builder {
|
|
b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
|
|
return b
|
|
}
|
|
|
|
// WithoutCompression disables HTTP compression.
|
|
func (b *Builder) WithoutCompression() *Builder {
|
|
b.enableCompression = false
|
|
return b
|
|
}
|
|
|
|
// WithCaching adds caching headers to the response.
|
|
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
|
|
etag = normalizeETag(etag)
|
|
b.headers.Set("ETag", etag)
|
|
// max-age is required for the "immutable" directive to take effect: without
|
|
// it, browsers still revalidate content-hashed assets on every reload.
|
|
b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
|
|
b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
|
|
|
|
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
|
|
b.statusCode = http.StatusNotModified
|
|
b.body = nil
|
|
b.Write()
|
|
} else {
|
|
callback(b)
|
|
}
|
|
}
|
|
|
|
// Write generates the HTTP response.
|
|
func (b *Builder) Write() {
|
|
if b.body == nil {
|
|
b.writeHeaders()
|
|
return
|
|
}
|
|
|
|
switch v := b.body.(type) {
|
|
case []byte:
|
|
b.compress(v)
|
|
case string:
|
|
b.compress([]byte(v))
|
|
case io.Reader:
|
|
// Compression not implemented in this case
|
|
b.writeHeaders()
|
|
_, err := io.Copy(b.w, v)
|
|
if err != nil {
|
|
slog.Error("Unable to write response body", slog.Any("error", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Builder) writeHeaders() {
|
|
b.headers.Set("X-Content-Type-Options", "nosniff")
|
|
b.headers.Set("X-Frame-Options", "DENY")
|
|
b.headers.Set("Referrer-Policy", "no-referrer")
|
|
|
|
maps.Copy(b.w.Header(), b.headers)
|
|
|
|
b.w.WriteHeader(b.statusCode)
|
|
}
|
|
|
|
// values should be in sync with [Builder.compress] switch/case.
|
|
var acceptEncoding = AcceptEncoding("br", "gzip", "deflate")
|
|
|
|
func (b *Builder) compress(data []byte) {
|
|
if b.enableCompression && len(data) > compressionThreshold {
|
|
b.headers.Set("Vary", "Accept-Encoding")
|
|
|
|
encoding := acceptEncoding.Parse(b.r.Header.Get("Accept-Encoding"))
|
|
switch encoding {
|
|
case "br":
|
|
b.headers.Set("Content-Encoding", "br")
|
|
b.writeHeaders()
|
|
|
|
brotliWriter := brotliWriterPool.Get().(*matchfinder.Writer)
|
|
brotliWriter.Reset(b.w)
|
|
brotliWriter.Write(data)
|
|
brotliWriter.Close()
|
|
brotliWriter.Reset(io.Discard)
|
|
brotliWriterPool.Put(brotliWriter)
|
|
return
|
|
case "gzip":
|
|
b.headers.Set("Content-Encoding", "gzip")
|
|
b.writeHeaders()
|
|
|
|
gzipWriter := gzipWriterPool.Get().(*gzip.Writer)
|
|
gzipWriter.Reset(b.w)
|
|
gzipWriter.Write(data)
|
|
gzipWriter.Close()
|
|
gzipWriter.Reset(io.Discard)
|
|
gzipWriterPool.Put(gzipWriter)
|
|
return
|
|
case "deflate":
|
|
b.headers.Set("Content-Encoding", "deflate")
|
|
b.writeHeaders()
|
|
|
|
flateWriter := flateWriterPool.Get().(*flate.Writer)
|
|
flateWriter.Reset(b.w)
|
|
flateWriter.Write(data)
|
|
flateWriter.Close()
|
|
flateWriter.Reset(io.Discard)
|
|
flateWriterPool.Put(flateWriter)
|
|
return
|
|
}
|
|
}
|
|
|
|
b.writeHeaders()
|
|
b.w.Write(data)
|
|
}
|
|
|
|
func normalizeETag(etag string) string {
|
|
etag = strings.TrimSpace(etag)
|
|
if etag == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
|
|
return etag
|
|
}
|
|
return `"` + etag + `"`
|
|
}
|
|
|
|
func ifNoneMatch(headerValue, etag string) bool {
|
|
if headerValue == "" || etag == "" {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(headerValue) == "*" {
|
|
return true
|
|
}
|
|
// Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
|
|
return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
|
|
}
|
|
|
|
func formatContentDisposition(dispositionType, filename string) string {
|
|
if filename == "" {
|
|
return dispositionType
|
|
}
|
|
|
|
if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
|
|
return value
|
|
}
|
|
|
|
return dispositionType
|
|
}
|