From c1a5f3db5de44245fe4f92e2f5eab5ca83c90d44 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Wed, 7 Jan 2026 05:41:47 +0000 Subject: [PATCH] shelley: add HTTP compression with content-based ETags 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 --- 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(-) diff --git a/server/handlers.go b/server/handlers.go index 97ea0f565f5f40ed735423cd04e396480e4bb3da..2f735fce8a38de5a11b89738fc3873f652842f5b 100644 --- a/server/handlers.go +++ b/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/ - s.handleGetConversation(w, r, conversationID) - } else { - switch parts[1] { - case "stream": - // /conversation//stream - s.handleStreamConversation(w, r, conversationID) - case "chat": - // /conversation//chat - s.handleChatConversation(w, r, conversationID) - case "cancel": - // /conversation//cancel - s.handleCancelConversation(w, r, conversationID) - case "archive": - // /conversation//archive - s.handleArchiveConversation(w, r, conversationID) - case "unarchive": - // /conversation//unarchive - s.handleUnarchiveConversation(w, r, conversationID) - case "delete": - // /conversation//delete - s.handleDeleteConversation(w, r, conversationID) - case "rename": - // /conversation//rename - s.handleRenameConversation(w, r, conversationID) - default: - http.Error(w, "Not found", http.StatusNotFound) - } - } +// conversationMux returns a mux for /api/conversation//* routes +func (s *Server) conversationMux() *http.ServeMux { + mux := http.NewServeMux() + // GET /api/conversation/ - 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//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/ diff --git a/server/middleware.go b/server/middleware.go index 1581ba8a0a7c8c11231233fd965b6a4ed107f896..63dc79ee6b1ca09a0faf539cf94d5986a133891d 100644 --- a/server/middleware.go +++ b/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) + }) +} diff --git a/server/middleware_test.go b/server/middleware_test.go index 67a3eeb1a95ca8f1c42348aca1371355efd7e073..0548d99eba93525c3fbb40908211a4059d9b6d3d 100644 --- a/server/middleware_test.go +++ b/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()) + } +} diff --git a/server/server.go b/server/server.go index 3c5d421e52b6819abd56999601ae03a74d6c3767..9cb5f6ade00e061601b22dbc7987aad93e319801 100644 --- a/server/server.go +++ b/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())) } diff --git a/ui/embedfs.go b/ui/embedfs.go index 13f425f50aa519ab7caab923458b088bb46a95a4..6a18bff8ace469bcb1dd59068d41841893edb7b9 100644 --- a/ui/embedfs.go +++ b/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 +} diff --git a/ui/scripts/build.js b/ui/scripts/build.js index ffdc4a59bde6e141f33b4c89d2d03c0e102db52b..96dba7cb9c1bf392e3ce46b8019c0532dfb4fcab 100644 --- a/ui/scripts/build.js +++ b/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`);