diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c2f1693d882..fc88bc6e60d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -150,7 +150,7 @@ func Parse() { printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err)) } - if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil { + if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn(), config.Opts.BasePath()+"/offline"); err != nil { printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err)) } diff --git a/internal/http/response/builder.go b/internal/http/response/builder.go index 9b7978b1ab3..9697fe63a1e 100644 --- a/internal/http/response/builder.go +++ b/internal/http/response/builder.go @@ -17,7 +17,10 @@ import ( "github.com/andybalholm/brotli" ) -const compressionThreshold = 1024 +// CompressionThreshold is the minimum body size, in bytes, for which HTTP +// compression is applied. Smaller responses are sent uncompressed because the +// encoding overhead outweighs the savings. +const CompressionThreshold = 1024 // Builder generates HTTP responses. type Builder struct { @@ -27,6 +30,8 @@ type Builder struct { headers http.Header enableCompression bool body any + brotliBody []byte + gzipBody []byte } // NewBuilder creates a new response builder. @@ -64,6 +69,17 @@ func (b *Builder) WithBodyAsReader(body io.Reader) *Builder { return b } +// WithCompressedVariants provides precomputed Brotli and Gzip representations +// of the response body. When the client supports the matching encoding, the +// precomputed bytes are served directly. A nil slice simply means no variant +// is available for that encoding, in which case the builder handles it as +// usual (compressing on the fly, or sending it uncompressed for small bodies). +func (b *Builder) WithCompressedVariants(brotliBody, gzipBody []byte) *Builder { + b.brotliBody = brotliBody + b.gzipBody = gzipBody + 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)) @@ -131,33 +147,39 @@ func (b *Builder) writeHeaders() { } func (b *Builder) compress(data []byte) { - if b.enableCompression && len(data) > compressionThreshold { + if b.enableCompression && len(data) > CompressionThreshold { b.headers.Set("Vary", "Accept-Encoding") acceptEncoding := b.r.Header.Get("Accept-Encoding") + switch { case strings.Contains(acceptEncoding, "br"): b.headers.Set("Content-Encoding", "br") b.writeHeaders() - - brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression) - brotliWriter.Write(data) - brotliWriter.Close() + if b.brotliBody != nil { + b.w.Write(b.brotliBody) + return + } + writer := brotli.NewWriterV2(b.w, brotli.DefaultCompression) + writer.Write(data) + writer.Close() return case strings.Contains(acceptEncoding, "gzip"): b.headers.Set("Content-Encoding", "gzip") b.writeHeaders() - - gzipWriter := gzip.NewWriter(b.w) - gzipWriter.Write(data) - gzipWriter.Close() + if b.gzipBody != nil { + b.w.Write(b.gzipBody) + return + } + writer := gzip.NewWriter(b.w) + writer.Write(data) + writer.Close() return case strings.Contains(acceptEncoding, "deflate"): b.headers.Set("Content-Encoding", "deflate") b.writeHeaders() - - flateWriter, _ := flate.NewWriter(b.w, -1) - flateWriter.Write(data) - flateWriter.Close() + writer, _ := flate.NewWriter(b.w, -1) + writer.Write(data) + writer.Close() return } } diff --git a/internal/http/response/builder_test.go b/internal/http/response/builder_test.go index cb5ff54e8b4..5846baa20f4 100644 --- a/internal/http/response/builder_test.go +++ b/internal/http/response/builder_test.go @@ -356,7 +356,7 @@ func TestIfNoneMatch(t *testing.T) { } func TestBuildResponseWithBrotliCompression(t *testing.T) { - body := strings.Repeat("a", compressionThreshold+1) + body := strings.Repeat("a", CompressionThreshold+1) r, err := http.NewRequest("GET", "/", nil) r.Header.Set("Accept-Encoding", "gzip, deflate, br") if err != nil { @@ -380,7 +380,7 @@ func TestBuildResponseWithBrotliCompression(t *testing.T) { } func TestBuildResponseWithGzipCompression(t *testing.T) { - body := strings.Repeat("a", compressionThreshold+1) + body := strings.Repeat("a", CompressionThreshold+1) r, err := http.NewRequest("GET", "/", nil) r.Header.Set("Accept-Encoding", "gzip, deflate") if err != nil { @@ -404,7 +404,7 @@ func TestBuildResponseWithGzipCompression(t *testing.T) { } func TestBuildResponseWithDeflateCompression(t *testing.T) { - body := strings.Repeat("a", compressionThreshold+1) + body := strings.Repeat("a", CompressionThreshold+1) r, err := http.NewRequest("GET", "/", nil) r.Header.Set("Accept-Encoding", "deflate") if err != nil { @@ -434,7 +434,7 @@ func TestBuildResponseWithDeflateCompression(t *testing.T) { } func TestBuildResponseWithCompressionDisabled(t *testing.T) { - body := strings.Repeat("a", compressionThreshold+1) + body := strings.Repeat("a", CompressionThreshold+1) r, err := http.NewRequest("GET", "/", nil) r.Header.Set("Accept-Encoding", "deflate") if err != nil { @@ -464,7 +464,7 @@ func TestBuildResponseWithCompressionDisabled(t *testing.T) { } func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) { - body := strings.Repeat("a", compressionThreshold) + body := strings.Repeat("a", CompressionThreshold) r, err := http.NewRequest("GET", "/", nil) r.Header.Set("Accept-Encoding", "deflate") if err != nil { @@ -494,7 +494,7 @@ func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) { } func TestBuildResponseWithoutCompressionHeader(t *testing.T) { - body := strings.Repeat("a", compressionThreshold+1) + body := strings.Repeat("a", CompressionThreshold+1) r, err := http.NewRequest("GET", "/", nil) if err != nil { t.Fatal(err) diff --git a/internal/ui/static/static.go b/internal/ui/static/static.go index e5a8417e404..068bcc7cbfb 100644 --- a/internal/ui/static/static.go +++ b/internal/ui/static/static.go @@ -5,22 +5,56 @@ package static // import "miniflux.app/v2/internal/ui/static" import ( "bytes" + "compress/gzip" "embed" + "fmt" "log/slog" "slices" "strings" "miniflux.app/v2/internal/crypto" + "miniflux.app/v2/internal/http/response" + "github.com/andybalholm/brotli" "github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2/css" "github.com/tdewolff/minify/v2/js" "github.com/tdewolff/minify/v2/svg" ) +// LibreJS license markers wrapped around the JavaScript bundles. +const licensePrefix = "//@license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\n" +const licenseSuffix = "\n//@license-end" + type asset struct { Data []byte Checksum string + // Precomputed Brotli and Gzip variants of Data, used to avoid + // recompressing immutable assets on every request. Nil when the asset is + // too small to be worth compressing. + Brotli []byte + Gzip []byte +} + +// precompressAsset computes the Brotli and Gzip variants of an immutable asset +// at maximum compression level. Both are nil for assets below the compression +// threshold. +func precompressAsset(data []byte) (brotliData, gzipData []byte) { + if len(data) <= response.CompressionThreshold { + return nil, nil + } + + var brotliBuffer bytes.Buffer + brotliWriter := brotli.NewWriterV2(&brotliBuffer, brotli.BestCompression) + brotliWriter.Write(data) + brotliWriter.Close() + + var gzipBuffer bytes.Buffer + gzipWriter, _ := gzip.NewWriterLevel(&gzipBuffer, gzip.BestCompression) + gzipWriter.Write(data) + gzipWriter.Close() + + return brotliBuffer.Bytes(), gzipBuffer.Bytes() } // Static assets. @@ -66,10 +100,18 @@ func GenerateBinaryBundles() error { } } - BinaryBundles[name] = asset{ + bundle := asset{ Data: data, Checksum: crypto.HashFromBytes(data), } + + // Only text-based SVG icons benefit from compression; raster images + // (PNG, ICO) are already compressed and served without it. + if strings.HasSuffix(name, ".svg") { + bundle.Brotli, bundle.Gzip = precompressAsset(data) + } + + BinaryBundles[name] = bundle } return nil @@ -108,9 +150,12 @@ func GenerateStylesheetsBundles() error { return err } + brotliData, gzipData := precompressAsset(minifiedData) StylesheetBundles[bundleName+".css"] = asset{ Data: minifiedData, Checksum: crypto.HashFromBytes(minifiedData), + Brotli: brotliData, + Gzip: gzipData, } } @@ -118,7 +163,7 @@ func GenerateStylesheetsBundles() error { } // GenerateJavascriptBundles creates JS bundles. -func GenerateJavascriptBundles(webauthnEnabled bool) error { +func GenerateJavascriptBundles(webauthnEnabled bool, offlineURL string) error { var bundles = map[string][]string{ "app": { "js/touch_handler.js", @@ -134,6 +179,10 @@ func GenerateJavascriptBundles(webauthnEnabled bool) error { bundles["app"] = slices.Insert(bundles["app"], 1, "js/webauthn_handler.js") } + // The service worker needs the offline URL, which is known at startup, so + // it is prepended once here instead of on every request. + serviceWorkerPrefix := fmt.Sprintf("const OFFLINE_URL=%q;", offlineURL) + JavascriptBundles = make(map[string]asset, len(bundles)) jsMinifier := js.Minifier{Version: 2020} @@ -144,6 +193,10 @@ func GenerateJavascriptBundles(webauthnEnabled bool) error { for bundleName, srcFiles := range bundles { var buffer bytes.Buffer + if bundleName == "service-worker" { + buffer.WriteString(serviceWorkerPrefix) + } + for _, srcFile := range srcFiles { fileData, err := javascriptFiles.ReadFile(srcFile) if err != nil { @@ -158,9 +211,20 @@ func GenerateJavascriptBundles(webauthnEnabled bool) error { return err } + // Wrap the minified bundle with the LibreJS license markers once, at + // generation time, so the served bytes are stable and can be + // precompressed. + wrappedData := make([]byte, 0, len(licensePrefix)+len(minifiedData)+len(licenseSuffix)) + wrappedData = append(wrappedData, licensePrefix...) + wrappedData = append(wrappedData, minifiedData...) + wrappedData = append(wrappedData, licenseSuffix...) + + brotliData, gzipData := precompressAsset(wrappedData) JavascriptBundles[bundleName+".js"] = asset{ - Data: minifiedData, - Checksum: crypto.HashFromBytes(minifiedData), + Data: wrappedData, + Checksum: crypto.HashFromBytes(wrappedData), + Brotli: brotliData, + Gzip: gzipData, } } diff --git a/internal/ui/static_app_icon.go b/internal/ui/static_app_icon.go index 206ab8f9abc..fd27c663533 100644 --- a/internal/ui/static_app_icon.go +++ b/internal/ui/static_app_icon.go @@ -29,6 +29,7 @@ func (h *handler) showAppIcon(w http.ResponseWriter, r *http.Request) { b.WithHeader("Content-Type", "image/png") case ".svg": b.WithHeader("Content-Type", "image/svg+xml") + b.WithCompressedVariants(value.Brotli, value.Gzip) } b.WithBodyAsBytes(value.Data) b.Write() diff --git a/internal/ui/static_javascript.go b/internal/ui/static_javascript.go index 79b029c83a7..b1a6d41a57d 100644 --- a/internal/ui/static_javascript.go +++ b/internal/ui/static_javascript.go @@ -4,9 +4,7 @@ package ui // import "miniflux.app/v2/internal/ui" import ( - "fmt" "net/http" - "strings" "time" "miniflux.app/v2/internal/http/response" @@ -14,9 +12,6 @@ import ( "miniflux.app/v2/internal/ui/static" ) -const licensePrefix = "//@license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\n" -const licenseSuffix = "\n//@license-end" - func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) { filename := r.PathValue("filename") javascriptBundle, found := static.JavascriptBundles[filename] @@ -26,19 +21,9 @@ func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) { } response.NewBuilder(w, r).WithCaching(javascriptBundle.Checksum, 48*time.Hour, func(b *response.Builder) { - contents := javascriptBundle.Data - - if filename == "service-worker.js" { - variables := fmt.Sprintf(`const OFFLINE_URL=%q;`, h.routePath("/offline")) - contents = append([]byte(variables), contents...) - } - - // cloning the prefix since `append` mutates its first argument - contents = append([]byte(strings.Clone(licensePrefix)), contents...) - contents = append(contents, []byte(licenseSuffix)...) - b.WithHeader("Content-Type", "text/javascript; charset=utf-8") - b.WithBodyAsBytes(contents) + b.WithBodyAsBytes(javascriptBundle.Data) + b.WithCompressedVariants(javascriptBundle.Brotli, javascriptBundle.Gzip) b.Write() }) } diff --git a/internal/ui/static_stylesheet.go b/internal/ui/static_stylesheet.go index 46884fd7fbb..69494ced474 100644 --- a/internal/ui/static_stylesheet.go +++ b/internal/ui/static_stylesheet.go @@ -22,6 +22,7 @@ func (h *handler) showStylesheet(w http.ResponseWriter, r *http.Request) { response.NewBuilder(w, r).WithCaching(stylesheetBundle.Checksum, 48*time.Hour, func(b *response.Builder) { b.WithHeader("Content-Type", "text/css; charset=utf-8") b.WithBodyAsBytes(stylesheetBundle.Data) + b.WithCompressedVariants(stylesheetBundle.Brotli, stylesheetBundle.Gzip) b.Write() }) }