staticrouter 2

This commit is contained in:
Sky Johnson 2025-03-13 16:43:08 -05:00
parent d8f7b9a1de
commit cc80485ae0
4 changed files with 760 additions and 81 deletions

View File

@ -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

View File

@ -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
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),
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
}
// Skip directories
if info.IsDir() {
return nil
// 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
}
// Get relative path for URL
relPath, err := filepath.Rel(r.rootDir, path)
if err != nil {
return err
// 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 + "/"
}
}
// Convert to URL path with forward slashes for consistency
urlPath := "/" + strings.ReplaceAll(relPath, "\\", "/")
// Check if client accepts gzip encoding
acceptsGzip := strings.Contains(req.Header.Get("Accept-Encoding"), "gzip")
// Add to routes map
r.routes[urlPath] = path
return nil
})
// 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
}
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)
}
}
// 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())
// 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),
}
}

View File

@ -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": "<html>User Profile</html>",
"posts/hello-world/comments.html": "<html>Post Comments</html>",
"docs/v1/api.html": "<html>API Docs</html>",
"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) {
@ -74,41 +88,50 @@ func TestStaticRouteMatching(t *testing.T) {
tests := []struct {
path string
wantFound bool
wantHandler string
}{
{"/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)
}
}

View File

@ -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
}