shelley: add HTTP compression with content-based ETags

Philip Zeyliger created

Prompt: Work on http compression in Shelley. We should return compressed data if the http client supports it. Ideally our large artifacts (like Monaco) are compressed at build time and served with the relevant headers for caching. Gzip is fine.

Follow-up: Since pretty much all clients support gzip, can we make our binary smaller by only keeping the compressed versions? Add a TODO in the SSE code to think about compression in the future. I'm worried about aggressive caching without any cache-busting mechanism. Since we have git version info, can we add ?sha=... to the relevant assets? Or can we use etags instead?

Follow-up 2: Use mime.TypeByExtension instead of custom function. Make gzip per-handler instead of middleware so we don't need path awareness.

Follow-up 3: Compute actual content checksums during build and use those as ETags instead of git SHA. This way monaco-editor.js gets cached effectively even across git commits if it didn't change.

Follow-up 4: Remove compression from small response handlers (chat, cancel, archive, etc). Convert handleConversation dispatcher to use http.ServeMux with path patterns.

Build-time compression:
- Generate gzip versions of JS/CSS with SHA256 checksums
- Only .gz files embedded (reduces binary size)
- Checksums stored in checksums.json for ETag generation

Static asset serving:
- Content-based ETags from checksums
- Returns 304 Not Modified on ETag match
- Cache-Control: public, max-age=31536000, immutable
- Decompresses on-the-fly for non-gzip clients

Per-handler gzip compression:
- gzipHandler() wraps handlers with large responses
- Small response handlers (version, chat, cancel, etc) not wrapped
- SSE stream not compressed (with TODO for future)
- /api/conversation/ routes use http.ServeMux with method+path patterns

Change summary

server/handlers.go        | 155 +++++++++++++++++++++++++++-------------
server/middleware.go      |  44 +++++++++++
server/middleware_test.go |  57 +++++++++++++++
server/server.go          |  36 ++++-----
ui/embedfs.go             |  14 +++
ui/scripts/build.js       |  43 ++++++++++-
6 files changed, 274 insertions(+), 75 deletions(-)

Detailed changes

server/handlers.go 🔗

@@ -1,6 +1,7 @@
 package server
 
 import (
+	"compress/gzip"
 	"context"
 	"crypto/rand"
 	"database/sql"
@@ -9,6 +10,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"mime"
 	"net/http"
 	"net/url"
 	"os"
@@ -22,6 +24,7 @@ import (
 	"shelley.exe.dev/llm"
 	"shelley.exe.dev/models"
 	"shelley.exe.dev/slug"
+	"shelley.exe.dev/ui"
 	"shelley.exe.dev/version"
 )
 
@@ -179,15 +182,23 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(map[string]string{"path": filename})
 }
 
-// staticHandler serves files from the provided filesystem and disables caching for HTML/CSS/JS to avoid stale bundles
-// isConversationSlugPath returns true if the path looks like a conversation slug route
-// (e.g., /c/my-conversation-slug)
+// staticHandler serves files from the provided filesystem.
+// For JS/CSS files, it serves pre-compressed .gz versions with content-based ETags.
 func isConversationSlugPath(path string) bool {
 	return strings.HasPrefix(path, "/c/")
 }
 
-func (s *Server) staticHandler(fs http.FileSystem) http.Handler {
-	fileServer := http.FileServer(fs)
+// acceptsGzip returns true if the client accepts gzip encoding
+func acceptsGzip(r *http.Request) bool {
+	return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
+}
+
+func (s *Server) staticHandler(fsys http.FileSystem) http.Handler {
+	fileServer := http.FileServer(fsys)
+
+	// Load checksums for ETag support (content-based, not git-based)
+	checksums := ui.Checksums()
+
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		// Inject initialization data into index.html
 		if r.URL.Path == "/" || r.URL.Path == "/index.html" || isConversationSlugPath(r.URL.Path) {
@@ -195,15 +206,64 @@ func (s *Server) staticHandler(fs http.FileSystem) http.Handler {
 			w.Header().Set("Pragma", "no-cache")
 			w.Header().Set("Expires", "0")
 			w.Header().Set("Content-Type", "text/html")
-			s.serveIndexWithInit(w, r, fs)
+			s.serveIndexWithInit(w, r, fsys)
 			return
 		}
 
-		if strings.HasSuffix(r.URL.Path, ".html") || strings.HasSuffix(r.URL.Path, ".js") || strings.HasSuffix(r.URL.Path, ".css") {
-			w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
-			w.Header().Set("Pragma", "no-cache")
-			w.Header().Set("Expires", "0")
+		// For JS and CSS files, serve from .gz files (only .gz versions are embedded)
+		if strings.HasSuffix(r.URL.Path, ".js") || strings.HasSuffix(r.URL.Path, ".css") {
+			gzPath := r.URL.Path + ".gz"
+			gzFile, err := fsys.Open(gzPath)
+			if err != nil {
+				// No .gz file, fall through to regular file server
+				fileServer.ServeHTTP(w, r)
+				return
+			}
+			defer gzFile.Close()
+
+			stat, err := gzFile.Stat()
+			if err != nil || stat.IsDir() {
+				fileServer.ServeHTTP(w, r)
+				return
+			}
+
+			// Get filename without leading slash for checksum lookup
+			filename := strings.TrimPrefix(r.URL.Path, "/")
+
+			// Check ETag for cache validation (content-based)
+			if checksums != nil {
+				if hash, ok := checksums[filename]; ok {
+					etag := `"` + hash + `"`
+					w.Header().Set("ETag", etag)
+					if r.Header.Get("If-None-Match") == etag {
+						w.WriteHeader(http.StatusNotModified)
+						return
+					}
+				}
+			}
+
+			w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(r.URL.Path)))
+			w.Header().Set("Vary", "Accept-Encoding")
+			// Cache for 1 year - ETag ensures revalidation works
+			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+
+			if acceptsGzip(r) {
+				// Client accepts gzip - serve compressed directly
+				w.Header().Set("Content-Encoding", "gzip")
+				io.Copy(w, gzFile)
+			} else {
+				// Rare: client doesn't accept gzip - decompress on the fly
+				gr, err := gzip.NewReader(gzFile)
+				if err != nil {
+					http.Error(w, "failed to decompress", http.StatusInternalServerError)
+					return
+				}
+				defer gr.Close()
+				io.Copy(w, gr)
+			}
+			return
 		}
+
 		fileServer.ServeHTTP(w, r)
 	})
 }
@@ -429,48 +489,39 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(conversations)
 }
 
-// handleConversation handles conversation-specific routes
-func (s *Server) handleConversation(w http.ResponseWriter, r *http.Request) {
-	path := strings.TrimPrefix(r.URL.Path, "/api/conversation/")
-	parts := strings.SplitN(path, "/", 2)
-	if len(parts) == 0 || parts[0] == "" {
-		http.Error(w, "Conversation ID required", http.StatusBadRequest)
-		return
-	}
-
-	conversationID := parts[0]
-
-	// Handle different endpoints
-	if len(parts) == 1 {
-		// /conversation/<id>
-		s.handleGetConversation(w, r, conversationID)
-	} else {
-		switch parts[1] {
-		case "stream":
-			// /conversation/<id>/stream
-			s.handleStreamConversation(w, r, conversationID)
-		case "chat":
-			// /conversation/<id>/chat
-			s.handleChatConversation(w, r, conversationID)
-		case "cancel":
-			// /conversation/<id>/cancel
-			s.handleCancelConversation(w, r, conversationID)
-		case "archive":
-			// /conversation/<id>/archive
-			s.handleArchiveConversation(w, r, conversationID)
-		case "unarchive":
-			// /conversation/<id>/unarchive
-			s.handleUnarchiveConversation(w, r, conversationID)
-		case "delete":
-			// /conversation/<id>/delete
-			s.handleDeleteConversation(w, r, conversationID)
-		case "rename":
-			// /conversation/<id>/rename
-			s.handleRenameConversation(w, r, conversationID)
-		default:
-			http.Error(w, "Not found", http.StatusNotFound)
-		}
-	}
+// conversationMux returns a mux for /api/conversation/<id>/* routes
+func (s *Server) conversationMux() *http.ServeMux {
+	mux := http.NewServeMux()
+	// GET /api/conversation/<id> - returns all messages (can be large, compress)
+	mux.Handle("GET /{id}", gzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		s.handleGetConversation(w, r, r.PathValue("id"))
+	})))
+	// GET /api/conversation/<id>/stream - SSE stream (do NOT compress)
+	// TODO: Consider gzip for SSE in the future. Would reduce bandwidth
+	// for large tool outputs, but needs flush after each event.
+	mux.HandleFunc("GET /{id}/stream", func(w http.ResponseWriter, r *http.Request) {
+		s.handleStreamConversation(w, r, r.PathValue("id"))
+	})
+	// POST endpoints - small responses, no compression needed
+	mux.HandleFunc("POST /{id}/chat", func(w http.ResponseWriter, r *http.Request) {
+		s.handleChatConversation(w, r, r.PathValue("id"))
+	})
+	mux.HandleFunc("POST /{id}/cancel", func(w http.ResponseWriter, r *http.Request) {
+		s.handleCancelConversation(w, r, r.PathValue("id"))
+	})
+	mux.HandleFunc("POST /{id}/archive", func(w http.ResponseWriter, r *http.Request) {
+		s.handleArchiveConversation(w, r, r.PathValue("id"))
+	})
+	mux.HandleFunc("POST /{id}/unarchive", func(w http.ResponseWriter, r *http.Request) {
+		s.handleUnarchiveConversation(w, r, r.PathValue("id"))
+	})
+	mux.HandleFunc("POST /{id}/delete", func(w http.ResponseWriter, r *http.Request) {
+		s.handleDeleteConversation(w, r, r.PathValue("id"))
+	})
+	mux.HandleFunc("POST /{id}/rename", func(w http.ResponseWriter, r *http.Request) {
+		s.handleRenameConversation(w, r, r.PathValue("id"))
+	})
+	return mux
 }
 
 // handleGetConversation handles GET /conversation/<id>

server/middleware.go 🔗

@@ -1,9 +1,11 @@
 package server
 
 import (
+	"compress/gzip"
 	"log/slog"
 	"net/http"
 	"strings"
+	"sync"
 
 	sloghttp "github.com/samber/slog-http"
 )
@@ -55,3 +57,45 @@ func RequireHeaderMiddleware(headerName string) func(http.Handler) http.Handler
 		})
 	}
 }
+
+// gzipResponseWriter wraps http.ResponseWriter to compress responses
+type gzipResponseWriter struct {
+	http.ResponseWriter
+	gw *gzip.Writer
+}
+
+func (w *gzipResponseWriter) Write(b []byte) (int, error) {
+	return w.gw.Write(b)
+}
+
+var gzipWriterPool = sync.Pool{
+	New: func() interface{} {
+		gw, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed)
+		return gw
+	},
+}
+
+// gzipHandler wraps a handler to compress responses when the client accepts gzip.
+// Use this to wrap specific handlers that benefit from compression.
+// Do NOT use for SSE or streaming responses.
+func gzipHandler(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		gw := gzipWriterPool.Get().(*gzip.Writer)
+		gw.Reset(w)
+		defer func() {
+			gw.Close()
+			gzipWriterPool.Put(gw)
+		}()
+
+		w.Header().Set("Content-Encoding", "gzip")
+		w.Header().Add("Vary", "Accept-Encoding")
+		w.Header().Del("Content-Length") // Compression changes size
+
+		next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, gw: gw}, r)
+	})
+}

server/middleware_test.go 🔗

@@ -1,6 +1,9 @@
 package server
 
 import (
+	"bytes"
+	"compress/gzip"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -142,3 +145,57 @@ func TestRequireHeaderMiddleware_AllowsVersionEndpointWithoutHeader(t *testing.T
 		t.Errorf("expected status 200 for /version without required header, got %d", w.Code)
 	}
 }
+
+func TestGzipHandler_CompressesResponse(t *testing.T) {
+	handler := gzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"message": "hello world"}`))
+	}))
+
+	req := httptest.NewRequest("GET", "/test", nil)
+	req.Header.Set("Accept-Encoding", "gzip")
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Header().Get("Content-Encoding") != "gzip" {
+		t.Errorf("expected Content-Encoding: gzip, got %q", w.Header().Get("Content-Encoding"))
+	}
+
+	// Verify we can decompress the response
+	gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
+	if err != nil {
+		t.Fatalf("failed to create gzip reader: %v", err)
+	}
+	defer gr.Close()
+
+	body, err := io.ReadAll(gr)
+	if err != nil {
+		t.Fatalf("failed to read gzip body: %v", err)
+	}
+
+	if !bytes.Contains(body, []byte("hello world")) {
+		t.Errorf("decompressed body doesn't contain expected content: %s", body)
+	}
+}
+
+func TestGzipHandler_SkipsWhenNoAcceptEncoding(t *testing.T) {
+	handler := gzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"message": "hello"}`))
+	}))
+
+	req := httptest.NewRequest("GET", "/test", nil)
+	// No Accept-Encoding header
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Header().Get("Content-Encoding") != "" {
+		t.Errorf("expected no Content-Encoding, got %q", w.Header().Get("Content-Encoding"))
+	}
+
+	if !bytes.Contains(w.Body.Bytes(), []byte("hello")) {
+		t.Errorf("body doesn't contain expected content: %s", w.Body.String())
+	}
+}

server/server.go 🔗

@@ -263,30 +263,28 @@ func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool
 
 // RegisterRoutes registers HTTP routes on the given mux
 func (s *Server) RegisterRoutes(mux *http.ServeMux) {
-	// API routes
-	mux.HandleFunc("/api/conversations", s.handleConversations)
-	mux.HandleFunc("/api/conversations/archived", s.handleArchivedConversations)
-	mux.HandleFunc("/api/conversations/new", s.handleNewConversation)
-	mux.HandleFunc("/api/conversation/", s.handleConversation)
-	mux.HandleFunc("/api/conversation-by-slug/", s.handleConversationBySlug)
-	mux.HandleFunc("/api/validate-cwd", s.handleValidateCwd)
-	mux.HandleFunc("/api/list-directory", s.handleListDirectory)
-	mux.HandleFunc("/api/git/diffs", s.handleGitDiffs)
-	mux.HandleFunc("/api/git/diffs/", s.handleGitDiffFiles)
-	mux.HandleFunc("/api/git/file-diff/", s.handleGitFileDiff)
-	mux.HandleFunc("/api/upload", s.handleUpload)
-
-	// Generic read route restricted to safe paths
-	mux.HandleFunc("/api/read", s.handleRead)
-	mux.HandleFunc("/api/write-file", s.handleWriteFile)
+	// API routes - wrap with gzip where beneficial
+	mux.Handle("/api/conversations", gzipHandler(http.HandlerFunc(s.handleConversations)))
+	mux.Handle("/api/conversations/archived", gzipHandler(http.HandlerFunc(s.handleArchivedConversations)))
+	mux.Handle("/api/conversations/new", http.HandlerFunc(s.handleNewConversation)) // Small response
+	mux.Handle("/api/conversation/", http.StripPrefix("/api/conversation", s.conversationMux()))
+	mux.Handle("/api/conversation-by-slug/", gzipHandler(http.HandlerFunc(s.handleConversationBySlug)))
+	mux.Handle("/api/validate-cwd", http.HandlerFunc(s.handleValidateCwd)) // Small response
+	mux.Handle("/api/list-directory", gzipHandler(http.HandlerFunc(s.handleListDirectory)))
+	mux.Handle("/api/git/diffs", gzipHandler(http.HandlerFunc(s.handleGitDiffs)))
+	mux.Handle("/api/git/diffs/", gzipHandler(http.HandlerFunc(s.handleGitDiffFiles)))
+	mux.Handle("/api/git/file-diff/", gzipHandler(http.HandlerFunc(s.handleGitFileDiff)))
+	mux.HandleFunc("/api/upload", s.handleUpload)                      // Binary uploads
+	mux.HandleFunc("/api/read", s.handleRead)                          // Serves images
+	mux.Handle("/api/write-file", http.HandlerFunc(s.handleWriteFile)) // Small response
 
 	// Version endpoint
-	mux.HandleFunc("/version", s.handleVersion)
+	mux.Handle("/version", http.HandlerFunc(s.handleVersion)) // Small response
 
 	// Debug routes
-	mux.HandleFunc("/debug/llm", s.handleDebugLLM)
+	mux.Handle("/debug/llm", gzipHandler(http.HandlerFunc(s.handleDebugLLM)))
 
-	// Serve embedded UI assets with conservative caching
+	// Serve embedded UI assets
 	mux.Handle("/", s.staticHandler(ui.Assets()))
 }
 

ui/embedfs.go 🔗

@@ -100,3 +100,17 @@ func checkStaleness() {
 func Assets() http.FileSystem {
 	return assets
 }
+
+// Checksums returns the content checksums for static assets.
+// These are computed during build and used for ETag generation.
+func Checksums() map[string]string {
+	data, err := fs.ReadFile(Dist, "dist/checksums.json")
+	if err != nil {
+		return nil
+	}
+	var checksums map[string]string
+	if err := json.Unmarshal(data, &checksums); err != nil {
+		return nil
+	}
+	return checksums
+}

ui/scripts/build.js 🔗

@@ -1,5 +1,7 @@
 import * as esbuild from 'esbuild';
 import * as fs from 'fs';
+import * as zlib from 'zlib';
+import * as crypto from 'crypto';
 
 const isWatch = process.argv.includes('--watch');
 const isProd = !isWatch;
@@ -65,10 +67,43 @@ async function build() {
 
     console.log('Build complete!');
 
-    // Show file sizes
-    console.log('\nOutput files:');
-    const files = fs.readdirSync('dist').filter(f => f.endsWith('.js') || f.endsWith('.css') || f.endsWith('.ttf'));
-    for (const file of files.sort()) {
+    // Generate gzip versions of large files and remove originals to reduce binary size
+    // The server will decompress on-the-fly for the rare clients that don't support gzip
+    console.log('\nGenerating gzip compressed files...');
+    const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'main.js', 'monaco-editor.css', 'styles.css'];
+    const checksums = {};
+    
+    for (const file of filesToCompress) {
+      const inputPath = `dist/${file}`;
+      const outputPath = `dist/${file}.gz`;
+      if (fs.existsSync(inputPath)) {
+        const input = fs.readFileSync(inputPath);
+        const compressed = zlib.gzipSync(input, { level: 9 });
+        fs.writeFileSync(outputPath, compressed);
+        
+        // Compute SHA256 of the compressed content for ETag
+        const hash = crypto.createHash('sha256').update(compressed).digest('hex').slice(0, 16);
+        checksums[file] = hash;
+        
+        const origKb = (input.length / 1024).toFixed(1);
+        const gzKb = (compressed.length / 1024).toFixed(1);
+        const ratio = ((compressed.length / input.length) * 100).toFixed(0);
+        console.log(`  ${file}: ${origKb} KB -> ${gzKb} KB gzip (${ratio}%) [${hash}]`);
+        
+        // Remove original to save space in embedded binary
+        fs.unlinkSync(inputPath);
+      }
+    }
+    
+    // Write checksums for ETag support
+    fs.writeFileSync('dist/checksums.json', JSON.stringify(checksums, null, 2));
+    console.log('\nChecksums written to dist/checksums.json');
+
+    console.log('\nOther files:');
+    const otherFiles = fs.readdirSync('dist').filter(f => 
+      (f.endsWith('.ttf') || f.endsWith('.map')) && !f.endsWith('.gz')
+    );
+    for (const file of otherFiles.sort()) {
       const stats = fs.statSync(`dist/${file}`);
       const sizeKb = (stats.size / 1024).toFixed(1);
       console.log(`  ${file}: ${sizeKb} KB`);