diff --git a/Containerfile b/Containerfile index dcc0107..5093cd6 100644 --- a/Containerfile +++ b/Containerfile @@ -30,7 +30,7 @@ COPY . . COPY --from=frontend-builder /app/dist /app/frontend/dist # Build the binary with embedded static files -RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o gocheck . +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-s -w" -o gocheck . # Stage 2: Create minimal runtime container FROM alpine:latest diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 08e31e9..cda2cc4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,7 +27,8 @@ "globals": "^16.3.0", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vite-plugin-compression2": "^2.2.0" } }, "node_modules/@ampproject/remapping": { @@ -824,6 +825,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.45.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", @@ -2362,6 +2399,13 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3544,6 +3588,13 @@ "node": ">=18" } }, + "node_modules/tar-mini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tar-mini/-/tar-mini-0.2.0.tgz", + "integrity": "sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -3747,6 +3798,17 @@ } } }, + "node_modules/vite-plugin-compression2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-2.2.0.tgz", + "integrity": "sha512-7BZlU2mBHbqoBGy0ARkn3tv/7LC/2h8ewVDpG/cyH8iSzLw6E/yH6P4oBOEvchkQxNpl+B5W6rFR5fdSwfDhMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "tar-mini": "^0.2.0" + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index c4a1211..c019adc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "globals": "^16.3.0", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vite-plugin-compression2": "^2.2.0" } } diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3a0ebb7..231a2a6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' import tailwindcss from '@tailwindcss/vite' +import { compression } from 'vite-plugin-compression2' // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), compression()], server: { host: '0.0.0.0', // Allow external access for Docker port: 5173, diff --git a/main.go b/main.go index 7a6e441..a564c6f 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,8 @@ import ( "sync" "time" + "path" + "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" ) @@ -486,6 +488,114 @@ func deleteItem(uuid string, id int) error { return err } +// ==== Static File Server with Compression Support ==== + +// compressionFileServer serves static files with compression support +func compressionFileServer(fsys fs.FS) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if this is a UUID path (starts with / followed by 36 characters) + isUUIDPath := len(r.URL.Path) == 37 && r.URL.Path[0] == '/' && + !strings.Contains(r.URL.Path[1:], "/") + + if r.URL.Path == "/" || isUUIDPath { + // Serve index.html at root or for routes with /{uuid} + content, err := fs.ReadFile(fsys, "frontend/dist/index.html") + if err != nil { + http.Error(w, "Not found", 404) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(content) + return + } + + // Get the requested file path relative to frontend/dist + filePath := strings.TrimPrefix(r.URL.Path, "/") + if filePath == "" { + filePath = "index.html" + } + fullPath := "frontend/dist/" + filePath + + // Check if client accepts compression + acceptEncoding := r.Header.Get("Accept-Encoding") + acceptsGzip := strings.Contains(acceptEncoding, "gzip") + acceptsBrotli := strings.Contains(acceptEncoding, "br") + + // Try to serve compressed version if client supports it + if acceptsBrotli { + if compressedContent, err := fs.ReadFile(fsys, fullPath+".br"); err == nil { + w.Header().Set("Content-Encoding", "br") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(compressedContent))) + w.Header().Set("Vary", "Accept-Encoding") + // Set appropriate content type based on file extension + setContentType(w, filePath) + w.Write(compressedContent) + return + } + } + + if acceptsGzip { + if compressedContent, err := fs.ReadFile(fsys, fullPath+".gz"); err == nil { + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(compressedContent))) + w.Header().Set("Vary", "Accept-Encoding") + // Set appropriate content type based on file extension + setContentType(w, filePath) + w.Write(compressedContent) + return + } + } + + // Fall back to uncompressed file + if content, err := fs.ReadFile(fsys, fullPath); err == nil { + w.Header().Set("Vary", "Accept-Encoding") + // Set appropriate content type based on file extension + setContentType(w, filePath) + w.Write(content) + return + } + + // File not found + http.NotFound(w, r) + }) +} + +// setContentType sets the appropriate Content-Type header based on file extension +func setContentType(w http.ResponseWriter, filePath string) { + ext := strings.ToLower(path.Ext(filePath)) + switch ext { + case ".html": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + case ".css": + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case ".js": + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + case ".json": + w.Header().Set("Content-Type", "application/json; charset=utf-8") + case ".svg": + w.Header().Set("Content-Type", "image/svg+xml") + case ".png": + w.Header().Set("Content-Type", "image/png") + case ".jpg", ".jpeg": + w.Header().Set("Content-Type", "image/jpeg") + case ".gif": + w.Header().Set("Content-Type", "image/gif") + case ".ico": + w.Header().Set("Content-Type", "image/x-icon") + case ".woff": + w.Header().Set("Content-Type", "font/woff") + case ".woff2": + w.Header().Set("Content-Type", "font/woff2") + case ".ttf": + w.Header().Set("Content-Type", "font/ttf") + case ".eot": + w.Header().Set("Content-Type", "application/vnd.ms-fontobject") + default: + // Default to octet-stream for unknown types + w.Header().Set("Content-Type", "application/octet-stream") + } +} + // ==== SSE Broadcast Logic ==== func broadcast(uuid string, msg interface{}) { @@ -894,30 +1004,7 @@ func main() { }) // Serve static files from embedded filesystem (register last) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Check if this is a UUID path (starts with / followed by 36 characters) - isUUIDPath := len(r.URL.Path) == 37 && r.URL.Path[0] == '/' && - !strings.Contains(r.URL.Path[1:], "/") - - if r.URL.Path == "/" || isUUIDPath { - // Serve index.html at root or for routes with /{uuid} - content, err := staticFiles.ReadFile("frontend/dist/index.html") - if err != nil { - http.Error(w, "Not found", 404) - return - } - w.Header().Set("Content-Type", "text/html") - w.Write(content) - } else { - // Serve other static files from the static/ subdirectory - subFS, err := fs.Sub(staticFiles, "frontend/dist") - if err != nil { - http.Error(w, "Not found", 404) - return - } - http.StripPrefix("/", http.FileServer(http.FS(subFS))).ServeHTTP(w, r) - } - }) + http.Handle("/", compressionFileServer(staticFiles)) port := strings.TrimSpace(os.Getenv("PORT")) if port == "" {