diff --git a/server/internal/handlers/static_handler.go b/server/internal/handlers/static_handler.go index d3f8d3e..0b2ffab 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,26 @@ 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)) + 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)) 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..0835cad 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"), } @@ -114,6 +118,46 @@ 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", + }, + { + 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", + }, + { + 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", + }, + { + 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", + }, } for _, tc := range tests {