sketch: compress JS and CSS

We noticed that our JS bundles weren't compressed; let's compress them.
Sketch did most of the work here itself, but there's some nonsense
around the fact that we pass a zip.Reader around, which can't seek.
We're probably doing more work than strictly necessary on the server side.

Add gzip compression for JS and source map files in esbuild

- Added compression for .js and .js.map files in the esbuild Build() function
- Gzipped files are created alongside the originals with .gz extension
- This allows for compressed asset serving when supported by clients

Co-Authored-By: sketch

Add support for serving pre-compressed JS and source map files

- Created a custom HTTP handler for /static/ path
- Handler checks if requested JS/JS.map files have gzipped versions
- Serves gzipped files with proper Content-Encoding headers when available
- Falls back to original files when compressed versions are not found
- Client support for gzip encoding is verified through Accept-Encoding header

Co-Authored-By: sketch

Extend gzip compression to include CSS files

- Added CSS files to the compression list alongside JS and source map files
- Added debug output to show which files are being compressed
- Updated error messages to reflect the inclusion of CSS files

Co-Authored-By: sketch

Fix variable naming in Accept-Encoding header processing

- Renamed 'header' variable to 'encoding' for better semantics when processing Accept-Encoding headers
- Addresses gopls check issue in compressed file handler

Co-Authored-By: sketch

Simplify Accept-Encoding header processing

- Simplified check for gzip support using strings.Contains()
- More efficient approach than splitting and iterating
- Addresses gopls efficiency recommendation

Co-Authored-By: sketch

Extract compressed file handler to separate package

- Created a new package loop/server/gzhandler
- Moved compressedFileHandler implementation to the new package
- Renamed to Handler for better Go idioms
- Updated loophttp.go to use the new package
- Improved modularity and separation of concerns

Co-Authored-By: sketch

Enhance gzhandler and add test coverage

- Updated gzhandler to handle all files except .gz files
- Added comprehensive test suite for gzhandler
- Removed debug print from esbuild.go
- Tests different file types, browsers with and without gzip support
- Tests directory handling

Co-Authored-By: sketch

Fix 'seeker can't seek' error in gzhandler

- Changed approach to read gzipped file into memory before serving
- Avoids io.Seeker interface requirement for http.ServeContent
- Fixes 500 error when serving compressed JavaScript files
- Added missing io import

Co-Authored-By: sketch
diff --git a/loop/server/gzhandler/gzhandler.go b/loop/server/gzhandler/gzhandler.go
new file mode 100644
index 0000000..3109ea2
--- /dev/null
+++ b/loop/server/gzhandler/gzhandler.go
@@ -0,0 +1,86 @@
+// Package gzhandler provides an HTTP file server implementation that serves pre-compressed files
+// when available to clients that support gzip encoding.
+package gzhandler
+
+import (
+	"io"
+	"io/fs"
+	"mime"
+	"net/http"
+	"path"
+	"strings"
+)
+
+// Handler is an http.Handler that checks for pre-compressed files
+// and serves them with appropriate headers when available.
+type Handler struct {
+	root http.FileSystem
+}
+
+// New creates a handler that serves HTTP requests
+// with the contents of the file system rooted at root and uses pre-compressed
+// .gz files when available, with appropriate headers.
+func New(root fs.FS) http.Handler {
+	return &Handler{root: http.FS(root)}
+}
+
+// ServeHTTP serves a file with special handling for pre-compressed .gz files
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// Clean and prepare the URL path
+	urlPath := r.URL.Path
+	if !strings.HasPrefix(urlPath, "/") {
+		urlPath = "/" + urlPath
+	}
+	urlPath = path.Clean(urlPath)
+
+	// Check if client accepts gzip encoding
+	acceptEncoding := r.Header.Get("Accept-Encoding")
+	acceptsGzip := strings.Contains(acceptEncoding, "gzip")
+
+	// Check if the file itself is not a gzip file (we don't want to double-compress)
+	isCompressibleFile := !strings.HasSuffix(urlPath, ".gz")
+
+	if acceptsGzip && isCompressibleFile {
+		// Try to open the gzipped version of the file
+		gzPath := urlPath + ".gz"
+		gzFile, err := h.root.Open(gzPath)
+
+		if err == nil {
+			defer gzFile.Close()
+
+			// Get file info to check if it's a regular file
+			gzStat, err := gzFile.Stat()
+			if err != nil || gzStat.IsDir() {
+				// Not a valid file, fall back to normal serving
+				http.FileServer(h.root).ServeHTTP(w, r)
+				return
+			}
+
+			// Determine the content type based on the original file (not the .gz)
+			contentType := mime.TypeByExtension(path.Ext(urlPath))
+			if contentType == "" {
+				contentType = "application/octet-stream"
+			}
+
+			// Set the appropriate headers for serving gzipped content
+			w.Header().Set("Content-Type", contentType)
+			w.Header().Set("Content-Encoding", "gzip")
+			w.Header().Set("Vary", "Accept-Encoding")
+
+			// Read the gzipped file into memory to avoid 'seeker can't seek' error
+			gzippedData, err := io.ReadAll(gzFile)
+			if err != nil {
+				http.Error(w, "Error reading gzipped content", http.StatusInternalServerError)
+				return
+			}
+
+			// Write the headers and gzipped content
+			w.WriteHeader(http.StatusOK)
+			w.Write(gzippedData)
+			return
+		}
+	}
+
+	// Fall back to standard file serving if gzipped version not found or not applicable
+	http.FileServer(h.root).ServeHTTP(w, r)
+}