fix: brotli compression ignored (#1661)

This commit is contained in:
Elias Schneider
2026-08-07 07:16:02 +02:00
committed by GitHub
parent 06ccadfcd0
commit 535e831b20
6 changed files with 155 additions and 148 deletions
+67 -62
View File
@@ -11,6 +11,7 @@ import (
"mime"
"net/http"
"path"
"strconv"
"strings"
"time"
@@ -21,6 +22,21 @@ import (
//go:embed all:dist/*
var frontendFS embed.FS
// SvelteKit generates both gzip and Brotli sidecars for these extensions when precompress is enabled
var precompressedExtensions = map[string]struct{}{
".css": {},
".html": {},
".js": {},
".json": {},
".md": {},
".mdx": {},
".mjs": {},
".svg": {},
".txt": {},
".wasm": {},
".xml": {},
}
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
var writeIndexFn func(w io.Writer, nonce string) error
@@ -59,14 +75,8 @@ func RegisterFrontend(router *gin.Engine) error {
return fmt.Errorf("failed to create sub FS: %w", err)
}
// Load a map of all files to see which ones are available pre-compressed
preCompressed, err := listPreCompressedAssets(distFS)
if err != nil {
return fmt.Errorf("failed to index pre-compressed frontend assets: %w", err)
}
// Init the file server
fileServer := NewFileServerWithCaching(http.FS(distFS), preCompressed)
fileServer := NewFileServerWithCaching(http.FS(distFS))
// Handler for Gin
handler := func(c *gin.Context) {
@@ -123,15 +133,13 @@ type FileServerWithCaching struct {
root http.FileSystem
lastModified time.Time
lastModifiedHeaderValue string
preCompressed preCompressedMap
}
func NewFileServerWithCaching(root http.FileSystem, preCompressed preCompressedMap) *FileServerWithCaching {
func NewFileServerWithCaching(root http.FileSystem) *FileServerWithCaching {
return &FileServerWithCaching{
root: root,
lastModified: time.Now(),
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
preCompressed: preCompressed,
}
}
@@ -158,14 +166,14 @@ func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request
w.Header().Set("Cache-Control", "public, max-age=86400")
}
// Check if the asset is available pre-compressed
_, ok := f.preCompressed[r.URL.Path]
// SvelteKit creates both sidecars for every asset with a precompressed extension
_, ok := precompressedExtensions[path.Ext(r.URL.Path)]
if ok {
// Add a "Vary" with "Accept-Encoding" so CDNs are aware that content is pre-compressed
w.Header().Add("Vary", "Accept-Encoding")
// Select the encoding if any
ext, ce := f.selectEncoding(r)
ext, ce := selectEncoding(r)
if ext != "" {
// Set the content type explicitly before changing the path
ct := mime.TypeByExtension(path.Ext(r.URL.Path))
@@ -182,23 +190,58 @@ func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request
http.FileServer(f.root).ServeHTTP(w, r)
}
func (f *FileServerWithCaching) selectEncoding(r *http.Request) (ext string, contentEnc string) {
available, ok := f.preCompressed[r.URL.Path]
if !ok {
return "", ""
}
// Check if the client accepts compressed files
acceptEncoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Accept-Encoding")))
func selectEncoding(r *http.Request) (ext string, contentEnc string) {
// Check which available encoding the client prefers
acceptEncoding := r.Header.Get("Accept-Encoding")
if acceptEncoding == "" {
return "", ""
}
// Prefer brotli over gzip when both are accepted.
if available.br && (acceptEncoding == "*" || acceptEncoding == "br" || strings.Contains(acceptEncoding, "br")) {
// Header can have multiple encodings with optional quality values, e.g. "gzip;q=1.0, br;q=0.8, *;q=0.5"
brWeight, gzipWeight, wildcardWeight := -1.0, -1.0, -1.0
for part := range strings.SplitSeq(acceptEncoding, ",") {
codingAndParams := strings.Split(part, ";")
coding := strings.ToLower(strings.TrimSpace(codingAndParams[0]))
weight := 1.0
for _, param := range codingAndParams[1:] {
key, value, found := strings.Cut(param, "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), "q") {
continue
}
// Parse the quality value
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || parsed < 0 || parsed > 1 {
weight = 0
break
}
weight = parsed
}
switch coding {
case "br":
brWeight = weight
case "gzip":
gzipWeight = weight
case "*":
wildcardWeight = weight
}
}
// Apply the wildcard only to encodings the client did not name explicitly
if brWeight < 0 {
brWeight = wildcardWeight
}
if gzipWeight < 0 {
gzipWeight = wildcardWeight
}
// Prefer brotli when both available encodings have the same quality
if brWeight > 0 && brWeight >= gzipWeight {
return "br", "br"
}
if available.gz && (acceptEncoding == "gzip" || strings.Contains(acceptEncoding, "gzip")) {
if gzipWeight > 0 {
return "gz", "gzip"
}
@@ -219,41 +262,3 @@ func isImmutableAsset(r *http.Request) bool {
return false
}
}
type preCompressedMap map[string]struct {
br bool
gz bool
}
func listPreCompressedAssets(distFS fs.FS) (preCompressedMap, error) {
preCompressed := make(preCompressedMap, 0)
err := fs.WalkDir(distFS, ".", func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
switch {
case strings.HasSuffix(path, ".br"):
originalPath := "/" + strings.TrimSuffix(path, ".br")
entry := preCompressed[originalPath]
entry.br = true
preCompressed[originalPath] = entry
case strings.HasSuffix(path, ".gz"):
originalPath := "/" + strings.TrimSuffix(path, ".gz")
entry := preCompressed[originalPath]
entry.gz = true
preCompressed[originalPath] = entry
}
return nil
})
if err != nil {
return nil, err
}
return preCompressed, nil
}
@@ -3,6 +3,8 @@
package frontend
import (
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
@@ -26,3 +28,85 @@ func TestIsSPARequest(t *testing.T) {
assert.True(t, isSPARequest("authorize", distFS))
})
}
func TestFileServerWithCachingServesPrecompressedAssets(t *testing.T) {
distFS := fstest.MapFS{
"assets/app.js": &fstest.MapFile{Data: []byte("original")},
"assets/app.js.br": &fstest.MapFile{Data: []byte("brotli")},
"assets/app.js.gz": &fstest.MapFile{Data: []byte("gzip")},
}
fileServer := NewFileServerWithCaching(http.FS(distFS))
tests := []struct {
name string
acceptEncoding string
expectedBody string
contentEncoding string
}{
{
name: "serves brotli when accepted",
acceptEncoding: "gzip, deflate, br",
expectedBody: "brotli",
contentEncoding: "br",
},
{
name: "serves gzip when brotli is not accepted",
acceptEncoding: "gzip",
expectedBody: "gzip",
contentEncoding: "gzip",
},
{
name: "honors encoding quality",
acceptEncoding: "br;q=0.5, gzip;q=1",
expectedBody: "gzip",
contentEncoding: "gzip",
},
{
name: "does not serve a rejected encoding",
acceptEncoding: "br;q=0, gzip;q=0",
expectedBody: "original",
contentEncoding: "",
},
{
name: "serves the original without accept encoding",
acceptEncoding: "",
expectedBody: "original",
contentEncoding: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", tt.acceptEncoding)
res := httptest.NewRecorder()
fileServer.ServeHTTP(res, req)
assert.Equal(t, http.StatusOK, res.Code)
assert.Equal(t, tt.expectedBody, res.Body.String())
assert.Equal(t, tt.contentEncoding, res.Header().Get("Content-Encoding"))
assert.Equal(t, "Accept-Encoding", res.Header().Get("Vary"))
assert.Equal(t, "text/javascript; charset=utf-8", res.Header().Get("Content-Type"))
})
}
}
func TestFileServerWithCachingDoesNotCompressUnsupportedExtensions(t *testing.T) {
distFS := fstest.MapFS{
"assets/font.woff2": &fstest.MapFile{Data: []byte("font")},
"assets/font.woff2.br": &fstest.MapFile{Data: []byte("unused")},
}
fileServer := NewFileServerWithCaching(http.FS(distFS))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/assets/font.woff2", nil)
req.Header.Set("Accept-Encoding", "br")
res := httptest.NewRecorder()
fileServer.ServeHTTP(res, req)
assert.Equal(t, http.StatusOK, res.Code)
assert.Equal(t, "font", res.Body.String())
assert.Empty(t, res.Header().Get("Content-Encoding"))
assert.Empty(t, res.Header().Get("Vary"))
}
+1 -2
View File
@@ -62,7 +62,6 @@
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.16",
"vite-plugin-compression": "^0.5.1"
"vite": "^8.0.16"
}
}
+2 -1
View File
@@ -20,7 +20,8 @@ const config = {
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
fallback: 'index.html',
pages: process.env.BUILD_OUTPUT_PATH ?? '../backend/frontend/dist'
pages: process.env.BUILD_OUTPUT_PATH ?? '../backend/frontend/dist',
precompress: true
}),
version: {
name: packageJson.version
+1 -18
View File
@@ -2,9 +2,8 @@ import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import viteCompression from 'vite-plugin-compression';
export default defineConfig((mode) => {
export default defineConfig(() => {
return {
plugins: [
sveltekit(),
@@ -15,22 +14,6 @@ export default defineConfig((mode) => {
emitTsDeclarations: true,
cookieName: 'locale',
strategy: ['cookie', 'preferredLanguage', 'baseLocale']
}),
// Create gzip-compressed files
viteCompression({
disable: mode.isPreview,
algorithm: 'gzip',
ext: '.gz',
filter: /\.(js|mjs|json|css)$/i
}),
// Create brotli-compressed files
viteCompression({
disable: mode.isPreview,
algorithm: 'brotliCompress',
ext: '.br',
filter: /\.(js|mjs|json|css)$/i
})
],
-65
View File
@@ -189,9 +189,6 @@ importers:
vite:
specifier: ^8.0.16
version: 8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)
vite-plugin-compression:
specifier: ^0.5.1
version: 0.5.1(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
tests:
dependencies:
@@ -1576,10 +1573,6 @@ packages:
caniuse-lite@1.0.30001800:
resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -1960,10 +1953,6 @@ packages:
svelte: ^5.0.0
sveltekit-superforms: ^2.19.0
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2012,10 +2001,6 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -2132,9 +2117,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2853,10 +2835,6 @@ packages:
resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==}
engines: {node: '>=14.0.0'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
svelte-check@4.7.3:
resolution: {integrity: sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==}
engines: {node: '>= 18.0.0'}
@@ -3014,10 +2992,6 @@ packages:
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unplugin@2.3.11:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
@@ -3051,11 +3025,6 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vite-plugin-compression@0.5.1:
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
peerDependencies:
vite: '>=2.0.0'
vite@8.1.5:
resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4352,11 +4321,6 @@ snapshots:
caniuse-lite@1.0.30001800: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -4756,12 +4720,6 @@ snapshots:
svelte-toolbelt: 0.5.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))
sveltekit-superforms: 2.30.2(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
fsevents@2.3.2:
optional: true
@@ -4808,8 +4766,6 @@ snapshots:
graceful-fs@4.2.11: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
@@ -4909,12 +4865,6 @@ snapshots:
json5@2.2.3: {}
jsonfile@6.2.1:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -5533,10 +5483,6 @@ snapshots:
superstruct@2.0.2:
optional: true
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
svelte-check@4.7.3(picomatch@4.0.5)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -5725,8 +5671,6 @@ snapshots:
undici-types@7.24.6: {}
universalify@2.0.1: {}
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -5754,15 +5698,6 @@ snapshots:
vary@1.1.2: {}
vite-plugin-compression@0.5.1(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)):
dependencies:
chalk: 4.1.2
debug: 4.4.3
fs-extra: 10.1.0
vite: 8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0):
dependencies:
lightningcss: 1.33.0