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/webui/esbuild.go b/loop/webui/esbuild.go
index d4af636..03c745e 100644
--- a/loop/webui/esbuild.go
+++ b/loop/webui/esbuild.go
@@ -1,12 +1,11 @@
 // Package webui provides the web interface for the sketch loop.
 // It bundles typescript files into JavaScript using esbuild.
-//
-// This is substantially the same mechanism as /esbuild.go in this repo as well.
 package webui
 
 import (
 	"archive/zip"
 	"bytes"
+	"compress/gzip"
 	"crypto/sha256"
 	"embed"
 	"encoding/hex"
@@ -204,6 +203,57 @@
 		return nil, fmt.Errorf("failed to write xterm.css: %w", err)
 	}
 
+	// Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
+	err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		}
+		// Check if file is a .js or .js.map file
+		if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
+			return nil
+		}
+
+		// Read the original file
+		origData, err := os.ReadFile(path)
+		if err != nil {
+			return fmt.Errorf("failed to read file %s: %w", path, err)
+		}
+
+		// Create a gzipped file
+		gzipPath := path + ".gz"
+		gzipFile, err := os.Create(gzipPath)
+		if err != nil {
+			return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
+		}
+		defer gzipFile.Close()
+
+		// Create a gzip writer
+		gzWriter := gzip.NewWriter(gzipFile)
+		defer gzWriter.Close()
+
+		// Write the original file content to the gzip writer
+		_, err = gzWriter.Write(origData)
+		if err != nil {
+			return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
+		}
+
+		// Ensure we flush and close properly
+		if err := gzWriter.Close(); err != nil {
+			return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
+		}
+		if err := gzipFile.Close(); err != nil {
+			return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
+	}
+
 	// Everything succeeded, so we write tmpHashDir to hashZip
 	buf := new(bytes.Buffer)
 	w := zip.NewWriter(buf)