Compare commits
No commits in common. "cc80485ae0b4d7c6a4337f4cfc99043875e45d5a" and "4f02f0e5bf25c88d08f6eadb37b4a859625fa34a" have entirely different histories.
cc80485ae0
...
4f02f0e5bf
|
@ -1,6 +1,2 @@
|
||||||
# Moonshark
|
# Moonshark
|
||||||
|
|
||||||
```bash
|
|
||||||
git submodule update --init --recursive
|
|
||||||
git submodule update --remote --recursive
|
|
||||||
```
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ package http
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -21,40 +20,16 @@ type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new HTTP server with optimized connection settings
|
// New creates a new HTTP server
|
||||||
func New(luaRouter *routers.LuaRouter, staticRouter *routers.StaticRouter, pool *workers.Pool, log *logger.Logger) *Server {
|
func New(luaRouter *routers.LuaRouter, staticRouter *routers.StaticRouter, pool *workers.Pool, log *logger.Logger) *Server {
|
||||||
server := &Server{
|
server := &Server{
|
||||||
luaRouter: luaRouter,
|
luaRouter: luaRouter,
|
||||||
staticRouter: staticRouter,
|
staticRouter: staticRouter,
|
||||||
workerPool: pool,
|
workerPool: pool,
|
||||||
logger: log,
|
logger: log,
|
||||||
httpServer: &http.Server{
|
httpServer: &http.Server{},
|
||||||
// Connection timeouts
|
|
||||||
ReadTimeout: 30 * time.Second,
|
|
||||||
WriteTimeout: 30 * time.Second,
|
|
||||||
IdleTimeout: 120 * time.Second,
|
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
|
|
||||||
// Improved connection handling
|
|
||||||
MaxHeaderBytes: 1 << 16, // 64KB
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
server.httpServer.Handler = server
|
server.httpServer.Handler = server
|
||||||
|
|
||||||
// Set TCP keep-alive settings for the underlying TCP connections
|
|
||||||
server.httpServer.ConnState = func(conn net.Conn, state http.ConnState) {
|
|
||||||
if state == http.StateNew {
|
|
||||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
|
||||||
// Enable TCP keep-alive
|
|
||||||
tcpConn.SetKeepAlive(true)
|
|
||||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
|
||||||
|
|
||||||
// Set TCP_NODELAY (disable Nagle's algorithm)
|
|
||||||
tcpConn.SetNoDelay(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,29 +44,46 @@ var levelProps = map[int]struct {
|
||||||
// Time format for log messages
|
// Time format for log messages
|
||||||
const timeFormat = "15:04:05"
|
const timeFormat = "15:04:05"
|
||||||
|
|
||||||
|
// logMessage represents a message to be logged
|
||||||
|
type logMessage struct {
|
||||||
|
level int
|
||||||
|
message string
|
||||||
|
rawMode bool // Indicates if raw formatting should be used
|
||||||
|
}
|
||||||
|
|
||||||
// Logger handles logging operations
|
// Logger handles logging operations
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
writer io.Writer
|
writer io.Writer
|
||||||
|
messages chan logMessage
|
||||||
|
wg sync.WaitGroup
|
||||||
level int
|
level int
|
||||||
useColors bool
|
useColors bool
|
||||||
|
done chan struct{}
|
||||||
timeFormat string
|
timeFormat string
|
||||||
mu sync.Mutex // Mutex for thread-safe writing
|
mu sync.Mutex // Mutex for thread-safe writing
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new logger
|
// New creates a new logger
|
||||||
func New(minLevel int, useColors bool) *Logger {
|
func New(minLevel int, useColors bool) *Logger {
|
||||||
return &Logger{
|
l := &Logger{
|
||||||
writer: os.Stdout,
|
writer: os.Stdout,
|
||||||
|
messages: make(chan logMessage, 100), // Buffer 100 messages
|
||||||
level: minLevel,
|
level: minLevel,
|
||||||
useColors: useColors,
|
useColors: useColors,
|
||||||
|
done: make(chan struct{}),
|
||||||
timeFormat: timeFormat,
|
timeFormat: timeFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l.wg.Add(1)
|
||||||
|
go l.processLogs()
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOutput changes the output destination
|
// SetOutput changes the output destination
|
||||||
func (l *Logger) SetOutput(w io.Writer) {
|
func (l *Logger) SetOutput(w io.Writer) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
l.writer = w
|
l.writer = w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,45 +112,67 @@ func (l *Logger) DisableColors() {
|
||||||
l.useColors = false
|
l.useColors = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeMessage writes a formatted log message directly to the writer
|
// processLogs processes incoming log messages
|
||||||
func (l *Logger) writeMessage(level int, message string, rawMode bool) {
|
func (l *Logger) processLogs() {
|
||||||
var logLine string
|
defer l.wg.Done()
|
||||||
|
|
||||||
if rawMode {
|
for {
|
||||||
// Raw mode - message is already formatted, just append newline
|
select {
|
||||||
logLine = message + "\n"
|
case msg := <-l.messages:
|
||||||
} else {
|
if msg.level >= l.level {
|
||||||
// Standard format with timestamp, level tag, and message
|
l.writeMessage(msg)
|
||||||
now := time.Now().Format(l.timeFormat)
|
}
|
||||||
props := levelProps[level]
|
case <-l.done:
|
||||||
|
// Process remaining messages
|
||||||
if l.useColors {
|
for {
|
||||||
logLine = fmt.Sprintf("%s %s[%s]%s %s\n",
|
select {
|
||||||
now, props.color, props.tag, colorReset, message)
|
case msg := <-l.messages:
|
||||||
} else {
|
if msg.level >= l.level {
|
||||||
logLine = fmt.Sprintf("%s [%s] %s\n",
|
l.writeMessage(msg)
|
||||||
now, props.tag, message)
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asynchronously write the log message
|
|
||||||
go func(w io.Writer, data string) {
|
|
||||||
l.mu.Lock()
|
|
||||||
_, _ = fmt.Fprint(w, data)
|
|
||||||
l.mu.Unlock()
|
|
||||||
}(l.writer, logLine)
|
|
||||||
|
|
||||||
// For fatal errors, ensure we sync immediately in the current goroutine
|
|
||||||
if level == LevelFatal {
|
|
||||||
l.mu.Lock()
|
|
||||||
if f, ok := l.writer.(*os.File); ok {
|
|
||||||
_ = f.Sync()
|
|
||||||
}
|
}
|
||||||
l.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// log handles the core logging logic with level filtering
|
// writeMessage writes a formatted log message
|
||||||
|
func (l *Logger) writeMessage(msg logMessage) {
|
||||||
|
var logLine string
|
||||||
|
|
||||||
|
if msg.rawMode {
|
||||||
|
// Raw mode - message is already formatted, just append newline
|
||||||
|
logLine = msg.message + "\n"
|
||||||
|
} else {
|
||||||
|
// Standard format with timestamp, level tag, and message
|
||||||
|
now := time.Now().Format(l.timeFormat)
|
||||||
|
props := levelProps[msg.level]
|
||||||
|
|
||||||
|
if l.useColors {
|
||||||
|
logLine = fmt.Sprintf("%s %s[%s]%s %s\n",
|
||||||
|
now, props.color, props.tag, colorReset, msg.message)
|
||||||
|
} else {
|
||||||
|
logLine = fmt.Sprintf("%s [%s] %s\n",
|
||||||
|
now, props.tag, msg.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronize writing
|
||||||
|
l.mu.Lock()
|
||||||
|
_, _ = fmt.Fprint(l.writer, logLine)
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
// Auto-flush for fatal errors
|
||||||
|
if msg.level == LevelFatal {
|
||||||
|
if f, ok := l.writer.(*os.File); ok {
|
||||||
|
_ = f.Sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// log sends a message to the logger goroutine
|
||||||
func (l *Logger) log(level int, format string, args ...any) {
|
func (l *Logger) log(level int, format string, args ...any) {
|
||||||
if level < l.level {
|
if level < l.level {
|
||||||
return
|
return
|
||||||
|
@ -146,10 +185,18 @@ func (l *Logger) log(level int, format string, args ...any) {
|
||||||
message = format
|
message = format
|
||||||
}
|
}
|
||||||
|
|
||||||
l.writeMessage(level, message, false)
|
// Don't block if channel is full
|
||||||
|
select {
|
||||||
|
case l.messages <- logMessage{level: level, message: message, rawMode: false}:
|
||||||
|
// Message sent
|
||||||
|
default:
|
||||||
|
// Channel full, write directly
|
||||||
|
l.writeMessage(logMessage{level: level, message: message, rawMode: false})
|
||||||
|
}
|
||||||
|
|
||||||
// Exit on fatal errors
|
// Exit on fatal errors
|
||||||
if level == LevelFatal {
|
if level == LevelFatal {
|
||||||
|
l.Close()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -171,10 +218,18 @@ func (l *Logger) LogRaw(format string, args ...any) {
|
||||||
// Don't apply colors if disabled
|
// Don't apply colors if disabled
|
||||||
if !l.useColors {
|
if !l.useColors {
|
||||||
// Strip ANSI color codes if colors are disabled
|
// Strip ANSI color codes if colors are disabled
|
||||||
|
// Simple approach to strip common ANSI codes
|
||||||
message = removeAnsiColors(message)
|
message = removeAnsiColors(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
l.writeMessage(LevelInfo, message, true)
|
// Don't block if channel is full
|
||||||
|
select {
|
||||||
|
case l.messages <- logMessage{level: LevelInfo, message: message, rawMode: true}:
|
||||||
|
// Message sent
|
||||||
|
default:
|
||||||
|
// Channel full, write directly
|
||||||
|
l.writeMessage(logMessage{level: LevelInfo, message: message, rawMode: true})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple helper to remove ANSI color codes
|
// Simple helper to remove ANSI color codes
|
||||||
|
@ -227,6 +282,13 @@ func (l *Logger) Fatal(format string, args ...any) {
|
||||||
// No need for os.Exit here as it's handled in log()
|
// No need for os.Exit here as it's handled in log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close shuts down the logger goroutine
|
||||||
|
func (l *Logger) Close() {
|
||||||
|
close(l.done)
|
||||||
|
l.wg.Wait()
|
||||||
|
close(l.messages)
|
||||||
|
}
|
||||||
|
|
||||||
// Default global logger
|
// Default global logger
|
||||||
var defaultLogger = New(LevelInfo, true)
|
var defaultLogger = New(LevelInfo, true)
|
||||||
|
|
||||||
|
@ -269,3 +331,8 @@ func SetLevel(level int) {
|
||||||
func SetOutput(w io.Writer) {
|
func SetOutput(w io.Writer) {
|
||||||
defaultLogger.SetOutput(w)
|
defaultLogger.SetOutput(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close shuts down the default logger
|
||||||
|
func Close() {
|
||||||
|
defaultLogger.Close()
|
||||||
|
}
|
||||||
|
|
|
@ -64,6 +64,8 @@ func TestLoggerLevels(t *testing.T) {
|
||||||
if !strings.Contains(buf.String(), "[ERR]") {
|
if !strings.Contains(buf.String(), "[ERR]") {
|
||||||
t.Errorf("Error message not logged after level change, got: %q", buf.String())
|
t.Errorf("Error message not logged after level change, got: %q", buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoggerConcurrency(t *testing.T) {
|
func TestLoggerConcurrency(t *testing.T) {
|
||||||
|
@ -94,6 +96,8 @@ func TestLoggerConcurrency(t *testing.T) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoggerColors(t *testing.T) {
|
func TestLoggerColors(t *testing.T) {
|
||||||
|
@ -120,6 +124,8 @@ func TestLoggerColors(t *testing.T) {
|
||||||
if strings.Contains(content, "\033[") {
|
if strings.Contains(content, "\033[") {
|
||||||
t.Errorf("Color codes present when disabled, got: %q", content)
|
t.Errorf("Color codes present when disabled, got: %q", content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultLogger(t *testing.T) {
|
func TestDefaultLogger(t *testing.T) {
|
||||||
|
@ -133,6 +139,8 @@ func TestDefaultLogger(t *testing.T) {
|
||||||
if !strings.Contains(content, "[INF]") {
|
if !strings.Contains(content, "[INF]") {
|
||||||
t.Errorf("Default logger not working, got: %q", content)
|
t.Errorf("Default logger not working, got: %q", content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkLogger(b *testing.B) {
|
func BenchmarkLogger(b *testing.B) {
|
||||||
|
@ -144,6 +152,7 @@ func BenchmarkLogger(b *testing.B) {
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
logger.Info("Benchmark message %d", i)
|
logger.Info("Benchmark message %d", i)
|
||||||
}
|
}
|
||||||
|
logger.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkLoggerParallel(b *testing.B) {
|
func BenchmarkLoggerParallel(b *testing.B) {
|
||||||
|
@ -159,4 +168,5 @@ func BenchmarkLoggerParallel(b *testing.B) {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
logger.Close()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,59 +1,22 @@
|
||||||
package routers
|
package routers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"container/list"
|
|
||||||
"errors"
|
"errors"
|
||||||
"mime"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.sharkk.net/Sky/Moonshark/core/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CacheEntry represents a cached file with metadata
|
// StaticRouter is a filesystem-based router for static files
|
||||||
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 {
|
type StaticRouter struct {
|
||||||
rootDir string // Root directory containing files
|
rootDir string // Root directory containing files
|
||||||
cache map[string]*list.Element // Cache map (URL path -> list element)
|
routes map[string]string // Direct mapping from URL path to file path
|
||||||
lruList *list.List // LRU tracking list
|
mu sync.RWMutex // Lock for concurrent access to routes
|
||||||
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
|
// NewStaticRouter creates a new StaticRouter instance
|
||||||
func NewStaticRouter(rootDir string) (*StaticRouter, error) {
|
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
|
// Verify root directory exists
|
||||||
info, err := os.Stat(rootDir)
|
info, err := os.Stat(rootDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -63,434 +26,63 @@ func NewStaticRouterWithLogger(rootDir string, log *logger.Logger) (*StaticRoute
|
||||||
return nil, errors.New("root path is not a directory")
|
return nil, errors.New("root path is not a directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the router with default settings
|
|
||||||
r := &StaticRouter{
|
r := &StaticRouter{
|
||||||
rootDir: rootDir,
|
rootDir: rootDir,
|
||||||
cache: make(map[string]*list.Element),
|
routes: make(map[string]string),
|
||||||
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)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize mime package with common types
|
// Build routes
|
||||||
mime.AddExtensionType(".js", "application/javascript")
|
if err := r.buildRoutes(); err != nil {
|
||||||
mime.AddExtensionType(".css", "text/css")
|
return nil, err
|
||||||
mime.AddExtensionType(".svg", "image/svg+xml")
|
}
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMaxItems sets the maximum number of items in the cache
|
// buildRoutes scans the root directory and builds the routing map
|
||||||
func (r *StaticRouter) SetMaxItems(n int) {
|
func (r *StaticRouter) buildRoutes() error {
|
||||||
if n <= 0 {
|
return filepath.Walk(r.rootDir, func(path string, info os.FileInfo, err error) error {
|
||||||
return
|
if err != nil {
|
||||||
}
|
return err
|
||||||
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
|
// Skip directories
|
||||||
origPath := req.URL.Path
|
if info.IsDir() {
|
||||||
fileURLPath := strings.TrimPrefix(req.URL.Path, r.urlPrefix)
|
return nil
|
||||||
|
|
||||||
// Make sure path starts with /
|
|
||||||
if !strings.HasPrefix(fileURLPath, "/") {
|
|
||||||
fileURLPath = "/" + fileURLPath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for directory access without trailing slash
|
// Get relative path for URL
|
||||||
if strings.HasSuffix(fileURLPath, "/index.html") {
|
relPath, err := filepath.Rel(r.rootDir, path)
|
||||||
dirPath := fileURLPath[:len(fileURLPath)-10] // remove "/index.html"
|
if err != nil {
|
||||||
filePath := filepath.Join(r.rootDir, filepath.FromSlash(strings.TrimPrefix(dirPath, "/")))
|
return err
|
||||||
if info, err := os.Stat(filePath); err == nil && info.IsDir() {
|
|
||||||
// This is a directory with index.html, handle it directly
|
|
||||||
fileURLPath = dirPath + "/"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if client accepts gzip encoding
|
// Convert to URL path with forward slashes for consistency
|
||||||
acceptsGzip := strings.Contains(req.Header.Get("Accept-Encoding"), "gzip")
|
urlPath := "/" + strings.ReplaceAll(relPath, "\\", "/")
|
||||||
|
|
||||||
// Copy the original request for modification
|
// Add to routes map
|
||||||
newReq := *req
|
r.routes[urlPath] = path
|
||||||
newReq.URL = new(url.URL)
|
return nil
|
||||||
*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
|
// Match finds a file path for the given URL path
|
||||||
func (r *StaticRouter) Match(urlPath string) (string, bool) {
|
func (r *StaticRouter) Match(path string) (string, bool) {
|
||||||
// Check if path starts with the prefix
|
|
||||||
if !strings.HasPrefix(urlPath, r.urlPrefix) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
r.mu.RLock()
|
||||||
elem, found := r.cache[urlPath]
|
defer r.mu.RUnlock()
|
||||||
if !found {
|
|
||||||
r.mu.RUnlock()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cache entry
|
filePath, found := r.routes[path]
|
||||||
entry := elem.Value.(*CacheEntry)
|
return filePath, found
|
||||||
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
|
// Refresh rebuilds the router by rescanning the root directory
|
||||||
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 {
|
func (r *StaticRouter) Refresh() error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
r.cache = make(map[string]*list.Element)
|
// Clear routes
|
||||||
r.lruList.Init()
|
r.routes = make(map[string]string)
|
||||||
r.currentSize = 0
|
|
||||||
return nil
|
// Rebuild routes
|
||||||
}
|
return r.buildRoutes()
|
||||||
|
|
||||||
// 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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,9 @@
|
||||||
package routers
|
package routers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.sharkk.net/Sky/Moonshark/core/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupStaticFiles(t *testing.T) (string, func()) {
|
func setupStaticFiles(t *testing.T) (string, func()) {
|
||||||
|
@ -27,8 +22,6 @@ func setupStaticFiles(t *testing.T) (string, func()) {
|
||||||
"users/123/profile.html": "<html>User Profile</html>",
|
"users/123/profile.html": "<html>User Profile</html>",
|
||||||
"posts/hello-world/comments.html": "<html>Post Comments</html>",
|
"posts/hello-world/comments.html": "<html>Post Comments</html>",
|
||||||
"docs/v1/api.html": "<html>API Docs</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 {
|
for path, content := range files {
|
||||||
|
@ -67,13 +60,6 @@ func TestStaticRouterInitialization(t *testing.T) {
|
||||||
if router == nil {
|
if router == nil {
|
||||||
t.Fatal("Router is 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) {
|
func TestStaticRouteMatching(t *testing.T) {
|
||||||
|
@ -88,51 +74,42 @@ func TestStaticRouteMatching(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string
|
path string
|
||||||
wantFound bool
|
wantFound bool
|
||||||
|
wantHandler string
|
||||||
}{
|
}{
|
||||||
{"/static/index.html", true},
|
{"/index.html", true, filepath.Join(rootDir, "index.html")},
|
||||||
{"/static/about.html", true},
|
{"/about.html", true, filepath.Join(rootDir, "about.html")},
|
||||||
{"/static/api/index.json", true},
|
{"/api/index.json", true, filepath.Join(rootDir, "api/index.json")},
|
||||||
{"/static/users/index.html", true},
|
{"/users/index.html", true, filepath.Join(rootDir, "users/index.html")},
|
||||||
{"/static/users/123/profile.html", true},
|
{"/users/123/profile.html", true, filepath.Join(rootDir, "users/123/profile.html")},
|
||||||
{"/static/posts/hello-world/comments.html", true},
|
{"/posts/hello-world/comments.html", true, filepath.Join(rootDir, "posts/hello-world/comments.html")},
|
||||||
{"/static/docs/v1/api.html", true},
|
{"/docs/v1/api.html", true, filepath.Join(rootDir, "docs/v1/api.html")},
|
||||||
{"/static/styles.css", true},
|
|
||||||
{"/static/script.js", true},
|
|
||||||
|
|
||||||
// Non-existent routes
|
// Non-existent routes
|
||||||
{"/static/nonexistent.html", false},
|
{"/nonexistent.html", false, ""},
|
||||||
{"/static/api/nonexistent.json", false},
|
{"/api/nonexistent.json", false, ""},
|
||||||
|
|
||||||
// Routes without prefix
|
|
||||||
{"/index.html", false},
|
|
||||||
{"/styles.css", false},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.path, func(t *testing.T) {
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
_, found := router.Match(tt.path)
|
filePath, found := router.Match(tt.path)
|
||||||
|
|
||||||
if found != tt.wantFound {
|
if found != tt.wantFound {
|
||||||
t.Errorf("Match() found = %v, want %v", 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TestStaticParamExtraction has been removed since we no longer extract parameters
|
||||||
|
|
||||||
func TestStaticRefresh(t *testing.T) {
|
func TestStaticRefresh(t *testing.T) {
|
||||||
rootDir, cleanup := setupStaticFiles(t)
|
rootDir, cleanup := setupStaticFiles(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
@ -149,272 +126,25 @@ func TestStaticRefresh(t *testing.T) {
|
||||||
t.Fatalf("Failed to create file: %v", err)
|
t.Fatalf("Failed to create file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File should be found with proper prefix
|
// Before refresh, file should not be found
|
||||||
_, found := router.Match("/static/new.html")
|
_, found := router.Match("/new.html")
|
||||||
if !found {
|
if found {
|
||||||
t.Errorf("New file should be found immediately")
|
t.Errorf("New file should not be found before refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test refresh clears cache
|
// Refresh router
|
||||||
err = router.Refresh()
|
err = router.Refresh()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to refresh router: %v", err)
|
t.Fatalf("Failed to refresh router: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File should still be found after refresh
|
// After refresh, file should be found
|
||||||
_, found = router.Match("/static/new.html")
|
filePath, found := router.Match("/new.html")
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("File should still be found after refresh")
|
t.Errorf("New file should be found after refresh")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if filePath != newFilePath {
|
||||||
func TestStaticRouterHTTP(t *testing.T) {
|
t.Errorf("Expected path %s, got %s", newFilePath, filePath)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2
luajit
2
luajit
|
@ -1 +1 @@
|
||||||
Subproject commit 13686b3e66b388a31d459fe95d1aa3bfa05aeb27
|
Subproject commit 98ca857d73956bf69a07641710b678c11681319f
|
|
@ -35,12 +35,11 @@ func initRouters(routesDir, staticDir string, log *logger.Logger) (*routers.LuaR
|
||||||
log.Info("Lua router initialized with routes from %s", routesDir)
|
log.Info("Lua router initialized with routes from %s", routesDir)
|
||||||
|
|
||||||
// Initialize static file router
|
// Initialize static file router
|
||||||
staticRouter, err := routers.NewStaticRouterWithLogger(staticDir, log)
|
staticRouter, err := routers.NewStaticRouter(staticDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to initialize static router: %v", err)
|
return nil, nil, fmt.Errorf("failed to initialize static router: %v", err)
|
||||||
}
|
}
|
||||||
log.Info("Static router initialized with files from %s", staticDir)
|
log.Info("Static router initialized with files from %s", staticDir)
|
||||||
staticRouter.EnableDebugLog()
|
|
||||||
|
|
||||||
return luaRouter, staticRouter, nil
|
return luaRouter, staticRouter, nil
|
||||||
}
|
}
|
||||||
|
@ -48,6 +47,7 @@ func initRouters(routesDir, staticDir string, log *logger.Logger) (*routers.LuaR
|
||||||
func main() {
|
func main() {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
log := logger.New(logger.LevelDebug, true)
|
log := logger.New(logger.LevelDebug, true)
|
||||||
|
defer log.Close()
|
||||||
|
|
||||||
log.Info("Starting Moonshark server")
|
log.Info("Starting Moonshark server")
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user