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_test.go b/loop/server/gzhandler/gzhandler_test.go
new file mode 100644
index 0000000..958f8ab
--- /dev/null
+++ b/loop/server/gzhandler/gzhandler_test.go
@@ -0,0 +1,240 @@
+package gzhandler
+
+import (
+	"compress/gzip"
+	"io"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"testing/fstest"
+)
+
+func TestHandler_ServeHTTP(t *testing.T) {
+	// Create a test filesystem with regular and gzipped files
+	testFS := fstest.MapFS{
+		"regular.txt": &fstest.MapFile{
+			Data: []byte("This is a regular text file"),
+			Mode: 0644,
+		},
+		"regular.txt.gz": &fstest.MapFile{
+			Data: compressString(t, "This is a regular text file"),
+			Mode: 0644,
+		},
+		"regular.js": &fstest.MapFile{
+			Data: []byte("console.log('Hello world');"),
+			Mode: 0644,
+		},
+		"regular.js.gz": &fstest.MapFile{
+			Data: compressString(t, "console.log('Hello world');"),
+			Mode: 0644,
+		},
+		"nogzip.css": &fstest.MapFile{
+			Data: []byte(".body { color: red; }"),
+			Mode: 0644,
+		},
+	}
+
+	// Create the handler using our test filesystem
+	handler := New(testFS)
+
+	// Define test cases
+	tests := []struct {
+		name               string
+		path               string
+		acceptGzip         bool
+		expectedStatus     int
+		expectedBody       string
+		expectedGzipHeader string
+		expectedType       string
+	}{
+		{
+			name:               "Serve gzipped text file when accepted",
+			path:               "/regular.txt",
+			acceptGzip:         true,
+			expectedStatus:     http.StatusOK,
+			expectedBody:       "This is a regular text file",
+			expectedGzipHeader: "gzip",
+			expectedType:       "text/plain; charset=utf-8",
+		},
+		{
+			name:               "Serve regular text file when gzip not accepted",
+			path:               "/regular.txt",
+			acceptGzip:         false,
+			expectedStatus:     http.StatusOK,
+			expectedBody:       "This is a regular text file",
+			expectedGzipHeader: "",
+			expectedType:       "text/plain; charset=utf-8",
+		},
+		{
+			name:               "Serve gzipped JS file when accepted",
+			path:               "/regular.js",
+			acceptGzip:         true,
+			expectedStatus:     http.StatusOK,
+			expectedBody:       "console.log('Hello world');",
+			expectedGzipHeader: "gzip",
+			expectedType:       "text/javascript; charset=utf-8",
+		},
+		{
+			name:               "Serve regular CSS file when gzip not available",
+			path:               "/nogzip.css",
+			acceptGzip:         true,
+			expectedStatus:     http.StatusOK,
+			expectedBody:       ".body { color: red; }",
+			expectedGzipHeader: "",
+			expectedType:       "text/css; charset=utf-8",
+		},
+		{
+			name:           "Return 404 for non-existent file",
+			path:           "/nonexistent.txt",
+			acceptGzip:     true,
+			expectedStatus: http.StatusNotFound,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			// Create a request for the specified path
+			req := httptest.NewRequest("GET", tc.path, nil)
+
+			// Set Accept-Encoding header if needed
+			if tc.acceptGzip {
+				req.Header.Set("Accept-Encoding", "gzip")
+			}
+
+			// Create a response recorder
+			rec := httptest.NewRecorder()
+
+			// Serve the request
+			handler.ServeHTTP(rec, req)
+
+			// Check status code
+			if rec.Code != tc.expectedStatus {
+				t.Errorf("Expected status %d, got %d", tc.expectedStatus, rec.Code)
+				return
+			}
+
+			// For non-200 responses, we don't check the body
+			if tc.expectedStatus != http.StatusOK {
+				return
+			}
+
+			// Check Content-Type header (skip for .txt files since MIME mappings can vary by OS)
+			if !strings.HasSuffix(tc.path, ".txt") {
+				contentType := rec.Header().Get("Content-Type")
+				if contentType != tc.expectedType {
+					t.Errorf("Expected Content-Type %q, got %q", tc.expectedType, contentType)
+				}
+			}
+
+			// Check Content-Encoding header
+			contentEncoding := rec.Header().Get("Content-Encoding")
+			if contentEncoding != tc.expectedGzipHeader {
+				t.Errorf("Expected Content-Encoding %q, got %q", tc.expectedGzipHeader, contentEncoding)
+			}
+
+			// Read response body
+			var bodyReader io.Reader = rec.Body
+
+			// If response is gzipped, decompress it
+			if contentEncoding == "gzip" {
+				gzReader, err := gzip.NewReader(rec.Body)
+				if err != nil {
+					t.Fatalf("Failed to create gzip reader: %v", err)
+				}
+				defer gzReader.Close()
+				bodyReader = gzReader
+			}
+
+			// Read and check body content
+			actualBody, err := io.ReadAll(bodyReader)
+			if err != nil {
+				t.Fatalf("Failed to read response body: %v", err)
+			}
+
+			if string(actualBody) != tc.expectedBody {
+				t.Errorf("Expected body %q, got %q", tc.expectedBody, string(actualBody))
+			}
+		})
+	}
+}
+
+// TestHandleDirectories tests that directories are handled properly
+func TestHandleDirectories(t *testing.T) {
+	// Create a test filesystem with a directory
+	testFS := fstest.MapFS{
+		"dir": &fstest.MapFile{
+			Mode: fs.ModeDir | 0755,
+		},
+		"dir/index.html": &fstest.MapFile{
+			Data: []byte("<html>Directory index</html>"),
+			Mode: 0644,
+		},
+		"dir/index.html.gz": &fstest.MapFile{
+			Data: compressString(t, "<html>Directory index</html>"),
+			Mode: 0644,
+		},
+	}
+
+	// Create the handler using our test filesystem
+	handler := New(testFS)
+
+	// Create a request for the directory
+	req := httptest.NewRequest("GET", "/dir/", nil)
+	req.Header.Set("Accept-Encoding", "gzip")
+
+	// Create a response recorder
+	rec := httptest.NewRecorder()
+
+	// Serve the request
+	handler.ServeHTTP(rec, req)
+
+	// Check status code should be 200 (directory index)
+	if rec.Code != http.StatusOK {
+		t.Errorf("Expected status 200, got %d", rec.Code)
+	}
+
+	// Note: Directory listings may not use gzip encoding by default with http.FileServer
+	// This is acceptable behavior, so we don't enforce gzip encoding for directories
+	contentEncoding := rec.Header().Get("Content-Encoding")
+
+	// Check if body contains the index content (after decompression)
+	var bodyReader io.Reader
+	if contentEncoding == "gzip" {
+		gzReader, err := gzip.NewReader(rec.Body)
+		if err != nil {
+			t.Fatalf("Failed to create gzip reader: %v", err)
+		}
+		defer gzReader.Close()
+		bodyReader = gzReader
+	} else {
+		bodyReader = rec.Body
+	}
+
+	body, err := io.ReadAll(bodyReader)
+	if err != nil {
+		t.Fatalf("Failed to read response body: %v", err)
+	}
+
+	if !strings.Contains(string(body), "Directory index") {
+		t.Errorf("Expected directory index content, got %q", string(body))
+	}
+}
+
+// Helper function to compress a string into gzip format
+func compressString(t *testing.T, s string) []byte {
+	var buf strings.Builder
+	gw := gzip.NewWriter(&buf)
+
+	_, err := gw.Write([]byte(s))
+	if err != nil {
+		t.Fatalf("Failed to write to gzip writer: %v", err)
+	}
+
+	if err := gw.Close(); err != nil {
+		t.Fatalf("Failed to close gzip writer: %v", err)
+	}
+
+	return []byte(buf.String())
+}