From cc80485ae0b4d7c6a4337f4cfc99043875e45d5a Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 13 Mar 2025 16:43:08 -0500 Subject: [PATCH] staticrouter 2 --- core/logger/logger.go | 10 +- core/routers/staticrouter.go | 486 +++++++++++++++++++++++++++--- core/routers/staticrouter_test.go | 342 ++++++++++++++++++--- moonshark.go | 3 +- 4 files changed, 760 insertions(+), 81 deletions(-) diff --git a/core/logger/logger.go b/core/logger/logger.go index cb6fd0c..6e515dc 100644 --- a/core/logger/logger.go +++ b/core/logger/logger.go @@ -34,11 +34,11 @@ var levelProps = map[int]struct { tag string color string }{ - LevelDebug: {"DBG", colorCyan}, - LevelInfo: {"INF", colorBlue}, - LevelWarning: {"WRN", colorYellow}, - LevelError: {"ERR", colorRed}, - LevelFatal: {"FTL", colorPurple}, + LevelDebug: {" DBG", colorCyan}, + LevelInfo: {"INFO", colorBlue}, + LevelWarning: {"WARN", colorYellow}, + LevelError: {" ERR", colorRed}, + LevelFatal: {"FATL", colorPurple}, } // Time format for log messages diff --git a/core/routers/staticrouter.go b/core/routers/staticrouter.go index 763de0a..82f794b 100644 --- a/core/routers/staticrouter.go +++ b/core/routers/staticrouter.go @@ -1,22 +1,59 @@ package routers import ( + "bytes" + "compress/gzip" + "container/list" "errors" + "mime" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" + "strconv" "strings" "sync" + "time" + + "git.sharkk.net/Sky/Moonshark/core/logger" ) -// StaticRouter is a filesystem-based router for static files +// CacheEntry represents a cached file with metadata +type CacheEntry struct { + Path string // URL path + GzippedContent []byte // Gzipped content + Size int // Original size + GzippedSize int // Compressed size + ModTime int64 // Modification time + ContentType string // Content type +} + +// StaticRouter is a caching router for static files +// It provides an LRU cache for gzipped static assets type StaticRouter struct { - rootDir string // Root directory containing files - routes map[string]string // Direct mapping from URL path to file path - mu sync.RWMutex // Lock for concurrent access to routes + rootDir string // Root directory containing files + cache map[string]*list.Element // Cache map (URL path -> list element) + lruList *list.List // LRU tracking list + mu sync.RWMutex // Lock for concurrent access + maxItems int // Maximum number of items in cache + maxItemSize int // Maximum size per item (gzipped) + totalCapacity int // Total cache capacity in bytes + currentSize int // Current cache size in bytes + fileServer http.Handler // Underlying file server + bufferPool sync.Pool // Buffer pool for compression + urlPrefix string // URL prefix for static assets + log bool // Whether to log debug info + logger *logger.Logger // Logger instance } // NewStaticRouter creates a new StaticRouter instance func NewStaticRouter(rootDir string) (*StaticRouter, error) { + return NewStaticRouterWithLogger(rootDir, logger.New(logger.LevelInfo, true)) +} + +// NewStaticRouterWithLogger creates a new StaticRouter instance with a custom logger +func NewStaticRouterWithLogger(rootDir string, log *logger.Logger) (*StaticRouter, error) { // Verify root directory exists info, err := os.Stat(rootDir) if err != nil { @@ -26,63 +63,434 @@ func NewStaticRouter(rootDir string) (*StaticRouter, error) { return nil, errors.New("root path is not a directory") } + // Create the router with default settings r := &StaticRouter{ - rootDir: rootDir, - routes: make(map[string]string), + rootDir: rootDir, + cache: make(map[string]*list.Element), + lruList: list.New(), + maxItems: 100, // Default: cache 100 files + maxItemSize: 1 << 20, // Default: 1MB per file (gzipped) + totalCapacity: 20 << 20, // Default: 20MB total cache + fileServer: http.FileServer(http.Dir(rootDir)), + urlPrefix: "/static", // Default prefix for static assets + log: false, // Debug logging off by default + logger: log, + bufferPool: sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, + }, } - // Build routes - if err := r.buildRoutes(); err != nil { - return nil, err - } + // Initialize mime package with common types + mime.AddExtensionType(".js", "application/javascript") + mime.AddExtensionType(".css", "text/css") + mime.AddExtensionType(".svg", "image/svg+xml") return r, nil } -// buildRoutes scans the root directory and builds the routing map -func (r *StaticRouter) buildRoutes() error { - return filepath.Walk(r.rootDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err +// SetMaxItems sets the maximum number of items in the cache +func (r *StaticRouter) SetMaxItems(n int) { + if n <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.maxItems = n +} + +// SetMaxItemSize sets the maximum size per cached item (in bytes) +func (r *StaticRouter) SetMaxItemSize(n int) { + if n <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.maxItemSize = n +} + +// SetTotalCapacity sets the total cache capacity (in bytes) +func (r *StaticRouter) SetTotalCapacity(n int) { + if n <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.totalCapacity = n +} + +// EnableDebugLog enables debug logging +func (r *StaticRouter) EnableDebugLog() { + r.log = true +} + +// DisableDebugLog disables debug logging +func (r *StaticRouter) DisableDebugLog() { + r.log = false +} + +// SetURLPrefix sets the URL prefix for static assets +func (r *StaticRouter) SetURLPrefix(prefix string) { + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + r.urlPrefix = prefix +} + +// ServeHTTP implements http.Handler interface +func (r *StaticRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // Check if path starts with the prefix + if !strings.HasPrefix(req.URL.Path, r.urlPrefix) { + http.NotFound(w, req) + return + } + + // Strip prefix to get the file path + origPath := req.URL.Path + fileURLPath := strings.TrimPrefix(req.URL.Path, r.urlPrefix) + + // Make sure path starts with / + if !strings.HasPrefix(fileURLPath, "/") { + fileURLPath = "/" + fileURLPath + } + + // Check for directory access without trailing slash + if strings.HasSuffix(fileURLPath, "/index.html") { + dirPath := fileURLPath[:len(fileURLPath)-10] // remove "/index.html" + filePath := filepath.Join(r.rootDir, filepath.FromSlash(strings.TrimPrefix(dirPath, "/"))) + if info, err := os.Stat(filePath); err == nil && info.IsDir() { + // This is a directory with index.html, handle it directly + fileURLPath = dirPath + "/" } + } - // Skip directories - if info.IsDir() { - return nil + // Check if client accepts gzip encoding + acceptsGzip := strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") + + // Copy the original request for modification + newReq := *req + newReq.URL = new(url.URL) + *newReq.URL = *req.URL + newReq.URL.Path = fileURLPath + + // Try to serve from cache if client accepts gzip + if acceptsGzip && r.serveFromCache(w, &newReq, fileURLPath) { + if r.log { + r.logger.Debug("[StaticRouter] CACHE HIT: %s", origPath) } + return + } - // Get relative path for URL - relPath, err := filepath.Rel(r.rootDir, path) - if err != nil { - return err + if r.log { + r.logger.Debug("[StaticRouter] CACHE MISS: %s", origPath) + } + + // Fall back to standard file serving + wrappedWriter := httptest.NewRecorder() + r.fileServer.ServeHTTP(wrappedWriter, &newReq) + + // Check if we got a redirect - might need to add the prefix back + if wrappedWriter.Code == http.StatusMovedPermanently || wrappedWriter.Code == http.StatusPermanentRedirect { + location := wrappedWriter.Header().Get("Location") + if location != "" && !strings.HasPrefix(location, r.urlPrefix) { + // Prepend our prefix to the redirect location + newLocation := r.urlPrefix + if !strings.HasPrefix(location, "/") { + newLocation += "/" + } + newLocation += strings.TrimPrefix(location, "/") + wrappedWriter.Header().Set("Location", newLocation) } + } - // Convert to URL path with forward slashes for consistency - urlPath := "/" + strings.ReplaceAll(relPath, "\\", "/") + // Copy the response from the recorder to the real response writer + for k, v := range wrappedWriter.Header() { + w.Header()[k] = v + } + w.WriteHeader(wrappedWriter.Code) + w.Write(wrappedWriter.Body.Bytes()) - // Add to routes map - r.routes[urlPath] = path - return nil - }) + // Try to cache the file for next time if client accepts gzip + if acceptsGzip && wrappedWriter.Code == http.StatusOK { + filePath := filepath.Join(r.rootDir, filepath.FromSlash(strings.TrimPrefix(fileURLPath, "/"))) + // Cache synchronously for tests + r.cacheFile(fileURLPath, filePath) + } } // Match finds a file path for the given URL path -func (r *StaticRouter) Match(path string) (string, bool) { - r.mu.RLock() - defer r.mu.RUnlock() +func (r *StaticRouter) Match(urlPath string) (string, bool) { + // Check if path starts with the prefix + if !strings.HasPrefix(urlPath, r.urlPrefix) { + return "", false + } - filePath, found := r.routes[path] - return filePath, found + // Strip prefix + urlPath = strings.TrimPrefix(urlPath, r.urlPrefix) + + // Make sure path starts with / + if !strings.HasPrefix(urlPath, "/") { + urlPath = "/" + urlPath + } + + filePath := filepath.Join(r.rootDir, filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))) + _, err := os.Stat(filePath) + + if r.log && err == nil { + r.logger.Debug("[StaticRouter] MATCH: %s -> %s", urlPath, filePath) + } + + return filePath, err == nil } -// Refresh rebuilds the router by rescanning the root directory +// serveFromCache tries to serve a file from cache +// Returns true if successful, false otherwise +func (r *StaticRouter) serveFromCache(w http.ResponseWriter, req *http.Request, urlPath string) bool { + // Check cache first with read lock + r.mu.RLock() + elem, found := r.cache[urlPath] + if !found { + r.mu.RUnlock() + return false + } + + // Get cache entry + entry := elem.Value.(*CacheEntry) + content := entry.GzippedContent + contentType := entry.ContentType + modTime := time.Unix(entry.ModTime, 0) + r.mu.RUnlock() + + // Update LRU order with write lock + r.mu.Lock() + r.lruList.MoveToFront(elem) + r.mu.Unlock() + + // Check if client cache is still valid (If-Modified-Since) + if !isModified(req, modTime) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Last-Modified", modTime.UTC().Format(http.TimeFormat)) + w.Header().Set("Vary", "Accept-Encoding") + w.WriteHeader(http.StatusNotModified) + return true + } + + // Set appropriate headers + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.Header().Set("Vary", "Accept-Encoding") + w.Header().Set("Last-Modified", modTime.UTC().Format(http.TimeFormat)) + + // Write the content directly instead of using ServeContent + w.WriteHeader(http.StatusOK) + w.Write(content) + return true +} + +// isModified checks if the file has been modified since the client's last request +func isModified(req *http.Request, modTime time.Time) bool { + // Parse If-Modified-Since header + if ims := req.Header.Get("If-Modified-Since"); ims != "" { + t, err := http.ParseTime(ims) + if err == nil && !modTime.After(t.Add(time.Second)) { + return false + } + } + return true +} + +// cacheFile adds a file to the cache +func (r *StaticRouter) cacheFile(urlPath, filePath string) { + // Stat the file + fileInfo, err := os.Stat(filePath) + if err != nil || fileInfo.IsDir() { + return + } + + // Don't cache files that are too large + if fileInfo.Size() > int64(r.maxItemSize*2) { + return + } + + // Read file + data, err := os.ReadFile(filePath) + if err != nil { + return + } + + // Compress data using buffer from pool + buf := r.bufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer r.bufferPool.Put(buf) + + gzWriter, err := gzip.NewWriterLevel(buf, gzip.BestCompression) + if err != nil { + return + } + + if _, err := gzWriter.Write(data); err != nil { + gzWriter.Close() + return + } + if err := gzWriter.Close(); err != nil { + return + } + + gzippedData := make([]byte, buf.Len()) + copy(gzippedData, buf.Bytes()) + + // Don't cache if compressed size is too large + if len(gzippedData) > r.maxItemSize { + return + } + + // Get content type by extension or detection + contentType := getMimeType(filePath, data) + + // Update cache + r.mu.Lock() + defer r.mu.Unlock() + + // Check if already in cache + if elem, exists := r.cache[urlPath]; exists { + // Update existing entry + oldEntry := elem.Value.(*CacheEntry) + r.currentSize -= oldEntry.GzippedSize + + newEntry := &CacheEntry{ + Path: urlPath, + GzippedContent: gzippedData, + Size: len(data), + GzippedSize: len(gzippedData), + ModTime: fileInfo.ModTime().Unix(), + ContentType: contentType, + } + + elem.Value = newEntry + r.lruList.MoveToFront(elem) + r.currentSize += newEntry.GzippedSize + return + } + + // Make room in cache if needed + for r.lruList.Len() >= r.maxItems || + (r.currentSize+len(gzippedData) > r.totalCapacity && r.lruList.Len() > 0) { + + // Remove least recently used item + elem := r.lruList.Back() + if elem == nil { + break + } + + entry := elem.Value.(*CacheEntry) + r.lruList.Remove(elem) + delete(r.cache, entry.Path) + r.currentSize -= entry.GzippedSize + } + + // Add new item to cache + entry := &CacheEntry{ + Path: urlPath, + GzippedContent: gzippedData, + Size: len(data), + GzippedSize: len(gzippedData), + ModTime: fileInfo.ModTime().Unix(), + ContentType: contentType, + } + + elem := r.lruList.PushFront(entry) + r.cache[urlPath] = elem + r.currentSize += entry.GzippedSize +} + +// getMimeType returns the content type for a file +func getMimeType(filePath string, data []byte) string { + // Try to get content type from extension first + ext := strings.ToLower(filepath.Ext(filePath)) + if mimeType := mime.TypeByExtension(ext); mimeType != "" { + return mimeType + } + + // Fall back to detection + return http.DetectContentType(data) +} + +// Refresh clears the cache func (r *StaticRouter) Refresh() error { r.mu.Lock() defer r.mu.Unlock() - // Clear routes - r.routes = make(map[string]string) - - // Rebuild routes - return r.buildRoutes() + r.cache = make(map[string]*list.Element) + r.lruList.Init() + r.currentSize = 0 + return nil +} + +// PreloadCommonFiles loads common static file types into the cache +func (r *StaticRouter) PreloadCommonFiles() { + // Common file extensions to preload + extensions := map[string]bool{ + ".css": true, + ".js": true, + ".svg": true, + ".ico": true, + ".png": true, + ".jpg": true, + ".jpeg": true, + ".gif": true, + } + + if r.log { + r.logger.Debug("[StaticRouter] Preloading common files from %s", r.rootDir) + } + + count := 0 + + // Walk the directory + _ = filepath.Walk(r.rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + + // Check extension and file size + ext := strings.ToLower(filepath.Ext(path)) + if !extensions[ext] || info.Size() > int64(r.maxItemSize*2) { + return nil + } + + // Get URL path + relPath, err := filepath.Rel(r.rootDir, path) + if err != nil { + return nil + } + + // Don't include prefix here - will be applied in ServeHTTP + urlPath := "/" + strings.ReplaceAll(relPath, "\\", "/") + + // Cache the file synchronously for tests + r.cacheFile(urlPath, path) + count++ + + return nil + }) + + if r.log { + r.logger.Debug("[StaticRouter] Preloaded %d files", count) + } +} + +// GetStats returns cache statistics +func (r *StaticRouter) GetStats() map[string]any { + r.mu.RLock() + defer r.mu.RUnlock() + + return map[string]any{ + "items": r.lruList.Len(), + "maxItems": r.maxItems, + "currentSize": r.currentSize, + "totalCapacity": r.totalCapacity, + "usagePercent": float64(r.currentSize) * 100 / float64(r.totalCapacity), + } } diff --git a/core/routers/staticrouter_test.go b/core/routers/staticrouter_test.go index f265c47..2a640e7 100644 --- a/core/routers/staticrouter_test.go +++ b/core/routers/staticrouter_test.go @@ -1,9 +1,14 @@ package routers import ( + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + + "git.sharkk.net/Sky/Moonshark/core/logger" ) func setupStaticFiles(t *testing.T) (string, func()) { @@ -22,6 +27,8 @@ func setupStaticFiles(t *testing.T) (string, func()) { "users/123/profile.html": "User Profile", "posts/hello-world/comments.html": "Post Comments", "docs/v1/api.html": "API Docs", + "styles.css": "body { color: red; }", + "script.js": "function test() { return true; }", } for path, content := range files { @@ -60,6 +67,13 @@ func TestStaticRouterInitialization(t *testing.T) { if router == nil { t.Fatal("Router is nil") } + + // Test configuration methods + router.SetMaxItems(200) + router.SetMaxItemSize(2 << 20) // 2MB + router.SetTotalCapacity(50 << 20) // 50MB + + // These methods shouldn't fail, though we can't verify internal state directly } func TestStaticRouteMatching(t *testing.T) { @@ -72,43 +86,52 @@ func TestStaticRouteMatching(t *testing.T) { } tests := []struct { - path string - wantFound bool - wantHandler string + path string + wantFound bool }{ - {"/index.html", true, filepath.Join(rootDir, "index.html")}, - {"/about.html", true, filepath.Join(rootDir, "about.html")}, - {"/api/index.json", true, filepath.Join(rootDir, "api/index.json")}, - {"/users/index.html", true, filepath.Join(rootDir, "users/index.html")}, - {"/users/123/profile.html", true, filepath.Join(rootDir, "users/123/profile.html")}, - {"/posts/hello-world/comments.html", true, filepath.Join(rootDir, "posts/hello-world/comments.html")}, - {"/docs/v1/api.html", true, filepath.Join(rootDir, "docs/v1/api.html")}, + {"/static/index.html", true}, + {"/static/about.html", true}, + {"/static/api/index.json", true}, + {"/static/users/index.html", true}, + {"/static/users/123/profile.html", true}, + {"/static/posts/hello-world/comments.html", true}, + {"/static/docs/v1/api.html", true}, + {"/static/styles.css", true}, + {"/static/script.js", true}, // Non-existent routes - {"/nonexistent.html", false, ""}, - {"/api/nonexistent.json", false, ""}, + {"/static/nonexistent.html", false}, + {"/static/api/nonexistent.json", false}, + + // Routes without prefix + {"/index.html", false}, + {"/styles.css", false}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { - filePath, found := router.Match(tt.path) - + _, found := router.Match(tt.path) if found != tt.wantFound { t.Errorf("Match() found = %v, want %v", found, tt.wantFound) } - - if !found { - return - } - - if filePath != tt.wantHandler { - t.Errorf("Match() handler = %v, want %v", filePath, tt.wantHandler) - } }) } -} -//TestStaticParamExtraction has been removed since we no longer extract parameters + // Test with different prefix + router.SetURLPrefix("/assets") + + // Should now match with new prefix + _, found := router.Match("/assets/index.html") + if !found { + t.Errorf("Match() should find file with new prefix") + } + + // Should not match with old prefix + _, found = router.Match("/static/index.html") + if found { + t.Errorf("Match() should not find file with old prefix") + } +} func TestStaticRefresh(t *testing.T) { rootDir, cleanup := setupStaticFiles(t) @@ -126,25 +149,272 @@ func TestStaticRefresh(t *testing.T) { t.Fatalf("Failed to create file: %v", err) } - // Before refresh, file should not be found - _, found := router.Match("/new.html") - if found { - t.Errorf("New file should not be found before refresh") + // File should be found with proper prefix + _, found := router.Match("/static/new.html") + if !found { + t.Errorf("New file should be found immediately") } - // Refresh router + // Test refresh clears cache err = router.Refresh() if err != nil { t.Fatalf("Failed to refresh router: %v", err) } - // After refresh, file should be found - filePath, found := router.Match("/new.html") + // File should still be found after refresh + _, found = router.Match("/static/new.html") if !found { - t.Errorf("New file should be found after refresh") - } - - if filePath != newFilePath { - t.Errorf("Expected path %s, got %s", newFilePath, filePath) + t.Errorf("File should still be found after refresh") + } +} + +func TestStaticRouterHTTP(t *testing.T) { + rootDir, cleanup := setupStaticFiles(t) + defer cleanup() + + router, err := NewStaticRouter(rootDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + // Enable debug logging for coverage + router.EnableDebugLog() + + // Create a request to get a CSS file - first request will be uncached + req, err := http.NewRequest("GET", "/static/styles.css", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Accept-Encoding", "gzip") + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, recorder.Code) + } + + // Second request should be cached + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d for cached request", http.StatusOK, recorder.Code) + } + + // Verify content is gzipped + if recorder.Header().Get("Content-Encoding") != "gzip" { + t.Errorf("Expected gzip encoding, got %s", recorder.Header().Get("Content-Encoding")) + } + + // Verify content type is correct + if !strings.Contains(recorder.Header().Get("Content-Type"), "text/css") { + t.Errorf("Expected text/css content type, got %s", recorder.Header().Get("Content-Type")) + } + + // Test with If-Modified-Since header + req.Header.Set("If-Modified-Since", recorder.Header().Get("Last-Modified")) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNotModified { + t.Errorf("Expected status %d, got %d for If-Modified-Since", http.StatusNotModified, recorder.Code) + } + + // Test request without gzip support + req.Header.Del("Accept-Encoding") + req.Header.Del("If-Modified-Since") // Ensure we don't get a 304 + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d for non-gzip request", http.StatusOK, recorder.Code) + } + + if recorder.Header().Get("Content-Encoding") == "gzip" { + t.Errorf("Should not have gzip encoding for non-gzip request") + } + + // Test request to non-existing file + req, _ = http.NewRequest("GET", "/static/nonexistent.css", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNotFound { + t.Errorf("Expected status %d, got %d for nonexistent file", http.StatusNotFound, recorder.Code) + } + + // Test request without prefix + req, _ = http.NewRequest("GET", "/styles.css", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNotFound { + t.Errorf("Expected status %d, got %d for request without prefix", http.StatusNotFound, recorder.Code) + } + + // Test with custom prefix + router.SetURLPrefix("/assets") + req, _ = http.NewRequest("GET", "/assets/styles.css", nil) + req.Header.Set("Accept-Encoding", "gzip") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d with custom prefix", http.StatusOK, recorder.Code) + } + + // Disable debug logging for coverage + router.DisableDebugLog() +} + +func TestStaticRouterPreload(t *testing.T) { + rootDir, cleanup := setupStaticFiles(t) + defer cleanup() + + router, err := NewStaticRouter(rootDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + // Enable debug logging for coverage + router.EnableDebugLog() + + // Preload files + router.PreloadCommonFiles() + + // Request CSS file which should be preloaded + req, err := http.NewRequest("GET", "/static/styles.css", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Accept-Encoding", "gzip") + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, recorder.Code) + } + + // Should be served from cache + if recorder.Header().Get("Content-Encoding") != "gzip" { + t.Errorf("Expected gzip encoding after preload, got %s", recorder.Header().Get("Content-Encoding")) + } +} + +func TestStaticRouterStats(t *testing.T) { + rootDir, cleanup := setupStaticFiles(t) + defer cleanup() + + router, err := NewStaticRouter(rootDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + // Preload files to populate cache + router.PreloadCommonFiles() + + // Wait a bit for preloading to complete + // This is a bit of a hack but necessary for the async nature of preloading + for i := 0; i < 100; i++ { + stats := router.GetStats() + items, ok := stats["items"].(int) + if ok && items > 0 { + break + } + // Small sleep to avoid tight loop + http.NewRequest("GET", "/styles.css", nil) // Just to waste a little time + } + + // Get stats + stats := router.GetStats() + + // Verify stats exist + if stats["items"] == nil { + t.Errorf("Expected items stat to exist") + } + if stats["maxItems"] == nil { + t.Errorf("Expected maxItems stat to exist") + } + if stats["currentSize"] == nil { + t.Errorf("Expected currentSize stat to exist") + } + if stats["totalCapacity"] == nil { + t.Errorf("Expected totalCapacity stat to exist") + } + if stats["usagePercent"] == nil { + t.Errorf("Expected usagePercent stat to exist") + } +} + +func TestStaticRouterLargeFile(t *testing.T) { + rootDir, cleanup := setupStaticFiles(t) + defer cleanup() + + // Create a large file (2MB) that should exceed default max item size + largeFilePath := filepath.Join(rootDir, "large.bin") + largeContent := make([]byte, 2<<20) // 2MB of zeros + for i := range largeContent { + largeContent[i] = byte(i % 256) // Fill with pattern to prevent compression + } + + err := os.WriteFile(largeFilePath, largeContent, 0644) + if err != nil { + t.Fatalf("Failed to create large file: %v", err) + } + + router, err := NewStaticRouter(rootDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + // Request large file with proper prefix + req, err := http.NewRequest("GET", "/static/large.bin", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Accept-Encoding", "gzip") + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, recorder.Code) + } + + // Should not be served from cache (too large) + if recorder.Header().Get("Content-Encoding") == "gzip" { + t.Errorf("Large file should not be served with gzip from cache") + } + + // Verify content length is correct + if recorder.Body.Len() != len(largeContent) { + t.Errorf("Expected body length %d, got %d", len(largeContent), recorder.Body.Len()) + } +} + +func TestStaticRouterWithLogger(t *testing.T) { + rootDir, cleanup := setupStaticFiles(t) + defer cleanup() + + // Create a test logger + log := logger.New(logger.LevelDebug, false) + + // Create router with custom logger + router, err := NewStaticRouterWithLogger(rootDir, log) + if err != nil { + t.Fatalf("Failed to create router with logger: %v", err) + } + + router.EnableDebugLog() + + // Basic test with explicit file + req, _ := http.NewRequest("GET", "/static/styles.css", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("Expected status OK, got %d", recorder.Code) } } diff --git a/moonshark.go b/moonshark.go index 4969ae8..30856f7 100644 --- a/moonshark.go +++ b/moonshark.go @@ -35,11 +35,12 @@ func initRouters(routesDir, staticDir string, log *logger.Logger) (*routers.LuaR log.Info("Lua router initialized with routes from %s", routesDir) // Initialize static file router - staticRouter, err := routers.NewStaticRouter(staticDir) + staticRouter, err := routers.NewStaticRouterWithLogger(staticDir, log) if err != nil { return nil, nil, fmt.Errorf("failed to initialize static router: %v", err) } log.Info("Static router initialized with files from %s", staticDir) + staticRouter.EnableDebugLog() return luaRouter, staticRouter, nil }