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
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
50 changes: 36 additions & 14 deletions internal/http/response/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,6 +30,8 @@ type Builder struct {
headers http.Header
enableCompression bool
body any
brotliBody []byte
gzipBody []byte
}

// NewBuilder creates a new response builder.
Expand Down Expand Up @@ -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 {

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.

One request would need only one variant.

It'd be much easier to resolve accept-encoding outside. Then you just need to disable compression and set raw body value.

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))
Expand Down Expand Up @@ -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
}
}
Expand Down
12 changes: 6 additions & 6 deletions internal/http/response/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 68 additions & 4 deletions internal/ui/static/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -108,17 +150,20 @@ func GenerateStylesheetsBundles() error {
return err
}

brotliData, gzipData := precompressAsset(minifiedData)
StylesheetBundles[bundleName+".css"] = asset{
Data: minifiedData,
Checksum: crypto.HashFromBytes(minifiedData),
Brotli: brotliData,
Gzip: gzipData,
}
}

return nil
}

// 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",
Expand All @@ -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}
Expand All @@ -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 {
Expand All @@ -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,
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/ui/static_app_icon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 2 additions & 17 deletions internal/ui/static_javascript.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@
package ui // import "miniflux.app/v2/internal/ui"

import (
"fmt"
"net/http"
"strings"
"time"

"miniflux.app/v2/internal/http/response"

"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]
Expand All @@ -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()
})
}
1 change: 1 addition & 0 deletions internal/ui/static_stylesheet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}
Loading