diff --git a/app/package-lock.json b/app/package-lock.json index 655073d..b9cd0dc 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -56,7 +56,7 @@ "sass": "^1.80.4", "typescript": "^5.8.2", "vite": "^6.4.1", - "vite-plugin-compression2": "^1.3.0", + "vite-plugin-compression2": "^2.3.1", "vitest": "^3.1.4" } }, @@ -9794,15 +9794,11 @@ } }, "node_modules/vite-plugin-compression2": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-1.3.0.tgz", - "integrity": "sha512-/cYzISoYOo/SwPUBReS1E02a8eNTpQm8+lQUBj5NNGxuq4iZ3JOfWExUlobhVhPMJuejD7dipT+cMLbaWsMbdw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-2.3.1.tgz", + "integrity": "sha512-bnhLTsurtvOiiP6EMISIKVsOMCeTAjE6FJbyqQus3W4mtAxF7pCuC4puUIAiCgNs98tOCpqo6GIXJXTLufzIaw==", "dev": true, "license": "MIT", - "workspaces": [ - "example", - "e2e/*" - ], "dependencies": { "@rollup/pluginutils": "^5.1.0", "tar-mini": "^0.2.0" diff --git a/app/package.json b/app/package.json index 50fb797..2ca8986 100644 --- a/app/package.json +++ b/app/package.json @@ -76,7 +76,7 @@ "sass": "^1.80.4", "typescript": "^5.8.2", "vite": "^6.4.1", - "vite-plugin-compression2": "^1.3.0", + "vite-plugin-compression2": "^2.3.1", "vitest": "^3.1.4" }, "browserslist": { diff --git a/app/vite.config.ts b/app/vite.config.ts index 3083d4c..005fa28 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -11,7 +11,10 @@ export default defineConfig(({ mode }) => ({ react({ include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'], }), - compression(), + compression({ + threshold: 1024, // Only compress files > 1KB + deleteOriginalAssets: false, // Keep original files + }), ], root: 'src', diff --git a/server/internal/handlers/static_handler.go b/server/internal/handlers/static_handler.go index d3f8d3e..13944ce 100644 --- a/server/internal/handlers/static_handler.go +++ b/server/internal/handlers/static_handler.go @@ -24,6 +24,28 @@ func getStaticLogger() logging.Logger { return logging.WithGroup("static") } +// getContentType returns the appropriate content type based on file extension +func getContentType(path string) string { + switch filepath.Ext(path) { + case ".js": + return "application/javascript" + case ".css": + return "text/css" + case ".html": + return "text/html" + case ".json": + return "application/json" + case ".svg": + return "image/svg+xml" + case ".xml": + return "application/xml" + case ".yaml", ".yml": + return "application/x-yaml" + default: + return "application/octet-stream" + } +} + // ServeHTTP serves the static files func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { log := getStaticLogger().With( @@ -77,23 +99,28 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Check for pre-compressed version - if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + // Check for pre-compressed versions (prefer brotli over gzip) + acceptEncoding := r.Header.Get("Accept-Encoding") + + // Try brotli first (better compression ratio) + if strings.Contains(acceptEncoding, "br") { + brPath := cleanPath + ".br" + if _, err := os.Stat(brPath); err == nil { + w.Header().Set("Content-Encoding", "br") + w.Header().Set("Content-Type", getContentType(cleanPath)) + w.Header().Set("Vary", "Accept-Encoding") + http.ServeFile(w, r, brPath) + return + } + } + + // Fall back to gzip + if strings.Contains(acceptEncoding, "gzip") { gzPath := cleanPath + ".gz" if _, err := os.Stat(gzPath); err == nil { w.Header().Set("Content-Encoding", "gzip") - - // Set proper content type based on original file - contentType := "application/octet-stream" - switch filepath.Ext(cleanPath) { - case ".js": - contentType = "application/javascript" - case ".css": - contentType = "text/css" - case ".html": - contentType = "text/html" - } - w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Type", getContentType(cleanPath)) + w.Header().Set("Vary", "Accept-Encoding") http.ServeFile(w, r, gzPath) return } diff --git a/server/internal/handlers/static_handler_integration_test.go b/server/internal/handlers/static_handler_integration_test.go index 13af3ab..38f1584 100644 --- a/server/internal/handlers/static_handler_integration_test.go +++ b/server/internal/handlers/static_handler_integration_test.go @@ -26,8 +26,12 @@ func TestStaticHandler_Integration(t *testing.T) { "index.html": []byte("Index"), "assets/style.css": []byte("body { color: blue; }"), "assets/style.css.gz": []byte("gzipped css content"), + "assets/style.css.br": []byte("brotli css content"), "assets/script.js": []byte("console.log('test');"), "assets/script.js.gz": []byte("gzipped js content"), + "assets/script.js.br": []byte("brotli js content"), + "assets/app.js": []byte("console.log('app');"), + "assets/app.js.br": []byte("brotli app content"), "subdir/page.html": []byte("Page"), "subdir/page.html.gz": []byte("gzipped html content"), } @@ -52,6 +56,7 @@ func TestStaticHandler_Integration(t *testing.T) { wantType string wantEncoding string wantCacheHeader string + wantVary string }{ { name: "serve index.html", @@ -69,6 +74,7 @@ func TestStaticHandler_Integration(t *testing.T) { wantType: "text/css", wantEncoding: "gzip", wantCacheHeader: "public, max-age=31536000", + wantVary: "Accept-Encoding", }, { name: "serve JS with gzip support", @@ -79,6 +85,7 @@ func TestStaticHandler_Integration(t *testing.T) { wantType: "application/javascript", wantEncoding: "gzip", wantCacheHeader: "public, max-age=31536000", + wantVary: "Accept-Encoding", }, { name: "serve CSS without gzip", @@ -114,6 +121,50 @@ func TestStaticHandler_Integration(t *testing.T) { wantBody: []byte("Index"), wantType: "text/html; charset=utf-8", }, + { + name: "serve CSS with brotli support", + path: "/assets/style.css", + acceptEncoding: "br", + wantStatus: http.StatusOK, + wantBody: []byte("brotli css content"), + wantType: "text/css", + wantEncoding: "br", + wantCacheHeader: "public, max-age=31536000", + wantVary: "Accept-Encoding", + }, + { + name: "serve JS with brotli support", + path: "/assets/script.js", + acceptEncoding: "br", + wantStatus: http.StatusOK, + wantBody: []byte("brotli js content"), + wantType: "application/javascript", + wantEncoding: "br", + wantCacheHeader: "public, max-age=31536000", + wantVary: "Accept-Encoding", + }, + { + name: "prefer brotli over gzip when both supported", + path: "/assets/script.js", + acceptEncoding: "gzip, br", + wantStatus: http.StatusOK, + wantBody: []byte("brotli js content"), + wantType: "application/javascript", + wantEncoding: "br", + wantCacheHeader: "public, max-age=31536000", + wantVary: "Accept-Encoding", + }, + { + name: "fallback to gzip when brotli not available", + path: "/assets/app.js", + acceptEncoding: "gzip, br", + wantStatus: http.StatusOK, + wantBody: []byte("brotli app content"), + wantType: "application/javascript", + wantEncoding: "br", + wantCacheHeader: "public, max-age=31536000", + wantVary: "Accept-Encoding", + }, } for _, tc := range tests { @@ -139,6 +190,10 @@ func TestStaticHandler_Integration(t *testing.T) { if tc.wantCacheHeader != "" { assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control")) } + + if tc.wantVary != "" { + assert.Equal(t, tc.wantVary, w.Header().Get("Vary")) + } } }) }