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`);