From 535e831b20c7a81c3323138c96e2a9fd89c64eb6 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 7 Aug 2026 07:16:02 +0200 Subject: [PATCH] fix: brotli compression ignored (#1661) --- backend/frontend/frontend_included.go | 129 +++++++++++---------- backend/frontend/frontend_included_test.go | 84 ++++++++++++++ frontend/package.json | 3 +- frontend/svelte.config.js | 3 +- frontend/vite.config.ts | 19 +-- pnpm-lock.yaml | 65 ----------- 6 files changed, 155 insertions(+), 148 deletions(-) diff --git a/backend/frontend/frontend_included.go b/backend/frontend/frontend_included.go index 8c963663..0a0d9de6 100644 --- a/backend/frontend/frontend_included.go +++ b/backend/frontend/frontend_included.go @@ -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 -} diff --git a/backend/frontend/frontend_included_test.go b/backend/frontend/frontend_included_test.go index 0e755abc..4b8bb737 100644 --- a/backend/frontend/frontend_included_test.go +++ b/backend/frontend/frontend_included_test.go @@ -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")) +} diff --git a/frontend/package.json b/frontend/package.json index b960033f..fff428f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index 1bd67d78..08f667ad 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -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 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d871e51e..466871aa 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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 }) ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c94dae5..25a6e37e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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