From fdef431280cefe663ae48d4461a5b335e13ea360 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 8 Mar 2026 08:55:37 +0100 Subject: [PATCH 01/17] fix(server): harden HTTP to HTTPS redirects against host header abuse --- cmd/static-web/main.go | 11 ++- config.toml.example | 5 ++ internal/config/config.go | 6 ++ internal/config/config_test.go | 11 +++ internal/server/server.go | 128 ++++++++++++++++++++++++++++++--- internal/server/server_test.go | 85 ++++++++++++++++++++++ 6 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 internal/server/server_test.go diff --git a/cmd/static-web/main.go b/cmd/static-web/main.go index 69dfa49..43cf72b 100644 --- a/cmd/static-web/main.go +++ b/cmd/static-web/main.go @@ -80,6 +80,7 @@ func runServe(args []string) { host := fs.String("host", "", "host/IP to listen on (default: all interfaces)") port := fs.Int("p", 0, "shorthand for --port") portLong := fs.Int("port", 0, "HTTP port to listen on (default: 8080)") + redirectHost := fs.String("redirect-host", "", "canonical host for HTTP to HTTPS redirects") tlsCert := fs.String("tls-cert", "", "path to TLS certificate file (PEM)") tlsKey := fs.String("tls-key", "", "path to TLS private key file (PEM)") tlsPort := fs.Int("tls-port", 0, "HTTPS port (default: 8443)") @@ -133,6 +134,7 @@ func runServe(args []string) { if err := applyFlagOverrides(cfg, flagOverrides{ host: *host, port: effectivePort, + redirectHost: *redirectHost, tlsCert: *tlsCert, tlsKey: *tlsKey, tlsPort: *tlsPort, @@ -193,6 +195,7 @@ func runServe(args []string) { type flagOverrides struct { host string port int + redirectHost string tlsCert string tlsKey string tlsPort int @@ -228,6 +231,9 @@ func applyFlagOverrides(cfg *config.Config, f flagOverrides) error { if f.tlsCert != "" { cfg.Server.TLSCert = f.tlsCert } + if f.redirectHost != "" { + cfg.Server.RedirectHost = f.redirectHost + } if f.tlsKey != "" { cfg.Server.TLSKey = f.tlsKey } @@ -325,8 +331,8 @@ func parseBytes(s string) (int64, error) { // logConfig writes the resolved configuration to the standard logger. func logConfig(cfg *config.Config) { - log.Printf("[config] server.addr=%s tls_addr=%s tls_cert=%q tls_key=%q", - cfg.Server.Addr, cfg.Server.TLSAddr, cfg.Server.TLSCert, cfg.Server.TLSKey) + log.Printf("[config] server.addr=%s tls_addr=%s redirect_host=%q tls_cert=%q tls_key=%q", + cfg.Server.Addr, cfg.Server.TLSAddr, cfg.Server.RedirectHost, cfg.Server.TLSCert, cfg.Server.TLSKey) log.Printf("[config] files.root=%q files.index=%q files.not_found=%q", cfg.Files.Root, cfg.Files.Index, cfg.Files.NotFound) log.Printf("[config] cache.enabled=%v cache.max_bytes=%d cache.max_file_size=%d", @@ -425,6 +431,7 @@ Serve flags: --config string path to TOML config file (default "config.toml") --host string host/IP to listen on (default: all interfaces) --port, -p int HTTP port (default 8080) + --redirect-host string canonical host for HTTP to HTTPS redirects --tls-cert string path to TLS certificate (PEM) --tls-key string path to TLS private key (PEM) --tls-port int HTTPS port (default 8443) diff --git a/config.toml.example b/config.toml.example index be83f49..380c5a3 100644 --- a/config.toml.example +++ b/config.toml.example @@ -7,6 +7,11 @@ addr = ":8080" # HTTPS listen address (requires tls_cert and tls_key to be set). tls_addr = ":8443" +# Canonical host used for HTTP → HTTPS redirects. +# Set this in production whenever tls_addr listens on all interfaces (e.g. ":443"). +# Example: "static.example.com" +redirect_host = "" + # Path to TLS certificate file (PEM). Leave empty to disable HTTPS. tls_cert = "" diff --git a/internal/config/config.go b/internal/config/config.go index d4f1077..a486eb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,9 @@ type ServerConfig struct { Addr string `toml:"addr"` // TLSAddr is the HTTPS listen address. Default: ":8443". TLSAddr string `toml:"tls_addr"` + // RedirectHost is the canonical host used for HTTP→HTTPS redirects when TLS is enabled. + // When empty, the server falls back to the host in TLSAddr if one is configured. + RedirectHost string `toml:"redirect_host"` // TLSCert is the path to the TLS certificate file. TLSCert string `toml:"tls_cert"` // TLSKey is the path to the TLS private key file. @@ -172,6 +175,9 @@ func applyEnvOverrides(cfg *Config) { if v := os.Getenv("STATIC_SERVER_TLS_ADDR"); v != "" { cfg.Server.TLSAddr = v } + if v := os.Getenv("STATIC_SERVER_REDIRECT_HOST"); v != "" { + cfg.Server.RedirectHost = v + } if v := os.Getenv("STATIC_SERVER_TLS_CERT"); v != "" { cfg.Server.TLSCert = v } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1192cba..ca790cb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,6 +21,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.Server.ReadHeaderTimeout != 5*time.Second { t.Errorf("Server.ReadHeaderTimeout = %v, want 5s", cfg.Server.ReadHeaderTimeout) } + if cfg.Server.RedirectHost != "" { + t.Errorf("Server.RedirectHost = %q, want empty string", cfg.Server.RedirectHost) + } if cfg.Server.ReadTimeout != 10*time.Second { t.Errorf("Server.ReadTimeout = %v, want 10s", cfg.Server.ReadTimeout) } @@ -48,6 +51,7 @@ func TestLoadFromTOML(t *testing.T) { toml := ` [server] addr = ":9090" +redirect_host = "cdn.example.com" read_timeout = "10s" [files] @@ -73,6 +77,9 @@ csp = "default-src 'none'" if cfg.Server.Addr != ":9090" { t.Errorf("Server.Addr = %q, want :9090", cfg.Server.Addr) } + if cfg.Server.RedirectHost != "cdn.example.com" { + t.Errorf("Server.RedirectHost = %q, want cdn.example.com", cfg.Server.RedirectHost) + } if cfg.Server.ReadTimeout != 10*time.Second { t.Errorf("Server.ReadTimeout = %v, want 10s", cfg.Server.ReadTimeout) } @@ -96,6 +103,7 @@ csp = "default-src 'none'" func TestEnvOverrides(t *testing.T) { t.Setenv("STATIC_SERVER_ADDR", ":7070") + t.Setenv("STATIC_SERVER_REDIRECT_HOST", "static.example.com") t.Setenv("STATIC_FILES_ROOT", "/env/root") t.Setenv("STATIC_CACHE_ENABLED", "false") t.Setenv("STATIC_CACHE_MAX_BYTES", "52428800") @@ -109,6 +117,9 @@ func TestEnvOverrides(t *testing.T) { if cfg.Server.Addr != ":7070" { t.Errorf("Server.Addr = %q, want :7070", cfg.Server.Addr) } + if cfg.Server.RedirectHost != "static.example.com" { + t.Errorf("Server.RedirectHost = %q, want static.example.com", cfg.Server.RedirectHost) + } if cfg.Files.Root != "/env/root" { t.Errorf("Files.Root = %q, want /env/root", cfg.Files.Root) } diff --git a/internal/server/server.go b/internal/server/server.go index b8b25f2..1c42ffe 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,10 +4,13 @@ package server import ( "context" "crypto/tls" + "errors" "fmt" "log" "net" "net/http" + "net/url" + "strconv" "strings" "sync" @@ -20,6 +23,8 @@ type Server struct { https *http.Server // nil when TLS is not configured } +var errInvalidRedirectHost = errors.New("invalid redirect host") + // New creates a Server from the provided configuration and handler. // HTTPS is only configured when both TLSCert and TLSKey are non-empty. // When TLS is configured, the HTTP server is replaced with a redirect handler @@ -30,19 +35,13 @@ func New(cfg *config.ServerConfig, secCfg *config.SecurityConfig, handler http.H httpHandler := handler if cfg.TLSCert != "" && cfg.TLSKey != "" { // Replace plain-HTTP handler with a permanent redirect to HTTPS. - tlsAddr := cfg.TLSAddr + redirectAuthority, err := redirectAuthority(cfg.RedirectHost, cfg.TLSAddr) httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host := r.Host - // Strip any existing port from the host. - if idx := strings.LastIndex(host, ":"); idx >= 0 { - host = host[:idx] - } - // Append HTTPS port only when it differs from the default 443. - target := "https://" + host - if tlsAddr != ":443" && !strings.HasSuffix(tlsAddr, ":443") { - target += tlsAddr + if err != nil { + http.Error(w, "Bad Request: invalid redirect host", http.StatusBadRequest) + return } - target += r.RequestURI + target := (&url.URL{Scheme: "https", Host: redirectAuthority, Path: r.URL.Path, RawPath: r.URL.RawPath, RawQuery: r.URL.RawQuery}).String() http.Redirect(w, r, target, http.StatusMovedPermanently) }) } @@ -197,6 +196,113 @@ func (s *Server) httpServer() *http.Server { return s.http } +func redirectAuthority(configuredHost, tlsAddr string) (string, error) { + if configuredHost != "" { + return authorityWithTLSPort(configuredHost, tlsAddr) + } + + host, _, err := net.SplitHostPort(tlsAddr) + if err != nil { + return "", errInvalidRedirectHost + } + host = strings.TrimSpace(host) + if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" { + return "", errInvalidRedirectHost + } + + return authorityWithTLSPort(host, tlsAddr) +} + +func authorityWithTLSPort(hostOrAuthority, tlsAddr string) (string, error) { + if strings.ContainsAny(hostOrAuthority, "/\\@?#%") { + return "", errInvalidRedirectHost + } + + host, port, hasPort, err := splitHostPortOptional(hostOrAuthority) + if err != nil { + return "", errInvalidRedirectHost + } + if !validRedirectHost(host) { + return "", errInvalidRedirectHost + } + if hasPort { + if !validPort(port) { + return "", errInvalidRedirectHost + } + return joinHostPort(host, port), nil + } + + _, tlsPort, err := net.SplitHostPort(tlsAddr) + if err != nil { + return "", errInvalidRedirectHost + } + if tlsPort == "443" { + return hostForURL(host), nil + } + return joinHostPort(host, tlsPort), nil +} + +func splitHostPortOptional(value string) (host, port string, hasPort bool, err error) { + value = strings.TrimSpace(value) + if value == "" { + return "", "", false, errInvalidRedirectHost + } + if h, p, err := net.SplitHostPort(value); err == nil { + return trimIPv6Brackets(h), p, true, nil + } + if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + return trimIPv6Brackets(value), "", false, nil + } + if strings.Count(value, ":") == 1 { + return "", "", false, errInvalidRedirectHost + } + return trimIPv6Brackets(value), "", false, nil +} + +func trimIPv6Brackets(host string) string { + return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") +} + +func validRedirectHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" || strings.ContainsAny(host, "/\\@?#%") { + return false + } + if ip := net.ParseIP(host); ip != nil { + return true + } + if len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return false + } + for _, r := range label { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + } + return true +} + +func hostForURL(host string) string { + if strings.Contains(host, ":") { + return "[" + host + "]" + } + return host +} + +func joinHostPort(host, port string) string { + return net.JoinHostPort(host, port) +} + +func validPort(port string) bool { + n, err := strconv.Atoi(port) + return err == nil && n >= 1 && n <= 65535 +} + // newListenConfig returns a net.ListenConfig with platform-specific options. // The actual implementation varies by OS (see server_linux.go / server_other.go). var newListenConfig = func() net.ListenConfig { diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..a6ba1e3 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,85 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/BackendStack21/static-web/internal/config" +) + +func TestNewRedirectsToConfiguredHost(t *testing.T) { + cfg := &config.ServerConfig{ + Addr: ":8080", + TLSAddr: ":8443", + RedirectHost: "static.example.com", + TLSCert: "server.crt", + TLSKey: "server.key", + } + + s := New(cfg, nil, http.NotFoundHandler()) + req := httptest.NewRequest(http.MethodGet, "http://attacker.test/assets/app.js?v=1", nil) + req.Host = "attacker.test" + rr := httptest.NewRecorder() + + s.httpServer().Handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusMovedPermanently { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusMovedPermanently) + } + if got := rr.Header().Get("Location"); got != "https://static.example.com:8443/assets/app.js?v=1" { + t.Fatalf("Location = %q, want %q", got, "https://static.example.com:8443/assets/app.js?v=1") + } +} + +func TestNewRedirectsToTLSAddrHostWhenConfigured(t *testing.T) { + cfg := &config.ServerConfig{ + Addr: ":8080", + TLSAddr: "secure.example.com:443", + TLSCert: "server.crt", + TLSKey: "server.key", + } + + s := New(cfg, nil, http.NotFoundHandler()) + req := httptest.NewRequest(http.MethodGet, "http://attacker.test/", nil) + req.Host = "attacker.test" + rr := httptest.NewRecorder() + + s.httpServer().Handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusMovedPermanently { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusMovedPermanently) + } + if got := rr.Header().Get("Location"); got != "https://secure.example.com/" { + t.Fatalf("Location = %q, want %q", got, "https://secure.example.com/") + } +} + +func TestNewRejectsRedirectWithoutCanonicalHost(t *testing.T) { + cfg := &config.ServerConfig{ + Addr: ":8080", + TLSAddr: ":8443", + TLSCert: "server.crt", + TLSKey: "server.key", + } + + s := New(cfg, nil, http.NotFoundHandler()) + req := httptest.NewRequest(http.MethodGet, "http://attacker.test/login", nil) + req.Host = "attacker.test" + rr := httptest.NewRecorder() + + s.httpServer().Handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } + if got := rr.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } +} + +func TestRedirectAuthorityRejectsInvalidConfiguredHost(t *testing.T) { + if _, err := redirectAuthority("https://evil.example/path", ":443"); err == nil { + t.Fatal("redirectAuthority accepted invalid configured host") + } +} From 5b7473cd570ac3813e4b89eeea23f9f457869d54 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 8 Mar 2026 08:55:44 +0100 Subject: [PATCH 02/17] fix(cache): enforce true no-cache mode and honor entry ttl --- cmd/static-web/main.go | 4 ++-- internal/cache/cache.go | 26 ++++++++++++++++++++++---- internal/cache/cache_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/cmd/static-web/main.go b/cmd/static-web/main.go index 43cf72b..23abff0 100644 --- a/cmd/static-web/main.go +++ b/cmd/static-web/main.go @@ -163,9 +163,9 @@ func runServe(args []string) { // Initialise the in-memory file cache (respects cfg.Cache.Enabled). var c *cache.Cache if cfg.Cache.Enabled { - c = cache.NewCache(cfg.Cache.MaxBytes) + c = cache.NewCache(cfg.Cache.MaxBytes, cfg.Cache.TTL) } else { - c = cache.NewCache(0) // zero-size cache effectively disables caching + c = nil } // Build the full middleware + handler chain. diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 0369f07..a5a4d91 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -38,6 +38,8 @@ type CachedFile struct { ContentType string // Size is the length of Data in bytes. Size int64 + // ExpiresAt is the cache entry expiry time. Zero means no expiry. + ExpiresAt time.Time } // totalSize returns the approximate byte footprint of the entry. @@ -62,6 +64,7 @@ type Cache struct { lru *lru.Cache[string, *CachedFile] mu sync.Mutex maxBytes int64 + ttl time.Duration curBytes atomic.Int64 hits atomic.Int64 misses atomic.Int64 @@ -69,12 +72,16 @@ type Cache struct { // NewCache creates a new Cache with the given maximum byte capacity. // If maxBytes is <= 0, a default of 256 MB is used. -func NewCache(maxBytes int64) *Cache { +// If ttl is provided and > 0, entries expire after that duration. +func NewCache(maxBytes int64, ttl ...time.Duration) *Cache { if maxBytes <= 0 { maxBytes = 256 * 1024 * 1024 } c := &Cache{maxBytes: maxBytes} + if len(ttl) > 0 && ttl[0] > 0 { + c.ttl = ttl[0] + } onEvict := func(_ string, f *CachedFile) { c.curBytes.Add(-f.totalSize()) @@ -93,11 +100,16 @@ func NewCache(maxBytes int64) *Cache { // Get returns the cached file for the given path key, or (nil, false) on miss. func (c *Cache) Get(path string) (*CachedFile, bool) { f, ok := c.lru.Get(path) - if ok { - c.hits.Add(1) - } else { + if !ok { + c.misses.Add(1) + return nil, false + } + if !f.ExpiresAt.IsZero() && time.Now().After(f.ExpiresAt) { + c.lru.Remove(path) c.misses.Add(1) + return nil, false } + c.hits.Add(1) return f, ok } @@ -115,6 +127,12 @@ func (c *Cache) Put(path string, f *CachedFile) { c.mu.Lock() defer c.mu.Unlock() + if c.ttl > 0 { + f.ExpiresAt = time.Now().Add(c.ttl) + } else { + f.ExpiresAt = time.Time{} + } + // If the key already exists, subtract its old size before adding. if old, ok := c.lru.Peek(path); ok { c.curBytes.Add(-old.totalSize()) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 9653ee2..0dbfc7f 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -246,6 +246,33 @@ func TestCacheGetAfterFlush(t *testing.T) { } } +func TestCacheEntryExpiresAfterTTL(t *testing.T) { + c := cache.NewCache(1024*1024, 20*time.Millisecond) + c.Put("/ttl", makeFile(100)) + + if _, ok := c.Get("/ttl"); !ok { + t.Fatal("expected cache hit before TTL expiry") + } + + time.Sleep(30 * time.Millisecond) + if _, ok := c.Get("/ttl"); ok { + t.Fatal("expected cache miss after TTL expiry") + } + if stats := c.Stats(); stats.EntryCount != 0 { + t.Fatalf("EntryCount = %d, want 0 after expiry eviction", stats.EntryCount) + } +} + +func TestCacheTTLZeroDoesNotExpireEntries(t *testing.T) { + c := cache.NewCache(1024 * 1024) + c.Put("/persist", makeFile(100)) + + time.Sleep(10 * time.Millisecond) + if _, ok := c.Get("/persist"); !ok { + t.Fatal("expected cache hit with zero TTL") + } +} + // TestCacheConcurrentPutGet exercises the cache under concurrent load. // This is a correctness test (not a benchmark); it ensures no data races. func TestCacheConcurrentPutGet(t *testing.T) { From 8e99c7170421fe6383449ac05e3fef4b06d09d37 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 8 Mar 2026 08:55:47 +0100 Subject: [PATCH 03/17] perf(handler): reduce cache hit overhead and cold-miss filesystem work --- internal/handler/file.go | 106 +++++---- internal/handler/file_test.go | 42 +++- internal/handler/middleware.go | 61 ++--- internal/headers/headers.go | 80 ++----- internal/headers/headers_test.go | 380 +++++-------------------------- 5 files changed, 206 insertions(+), 463 deletions(-) diff --git a/internal/handler/file.go b/internal/handler/file.go index b0b7fa9..773907f 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -25,9 +25,11 @@ import ( // FileHandler serves static files from disk with caching and compression support. type FileHandler struct { - cfg *config.Config - cache *cache.Cache - absRoot string // resolved once at construction time + cfg *config.Config + cache *cache.Cache + absRoot string // resolved once at construction time + notFoundData []byte + notFoundContentType string } // NewFileHandler creates a new FileHandler. @@ -39,7 +41,10 @@ func NewFileHandler(cfg *config.Config, c *cache.Cache) *FileHandler { // Fall back to the raw root; PathSafe will catch any traversal attempts. absRoot = cfg.Files.Root } - return &FileHandler{cfg: cfg, cache: c, absRoot: absRoot} + + h := &FileHandler{cfg: cfg, cache: c, absRoot: absRoot} + h.notFoundData, h.notFoundContentType = h.loadCustomNotFoundPage() + return h } // ServeHTTP handles an HTTP request by resolving and serving the requested file. @@ -58,12 +63,14 @@ func (h *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } } - // Fast path: for a plain file URL that is already cached, skip os.Stat // entirely. os.Stat is deferred to the cache-miss branch below. - cacheKey := urlPath - if h.cfg.Cache.Enabled { + cacheKey := headers.CacheKeyForPath(urlPath, h.cfg.Files.Index) + if h.cfg.Cache.Enabled && h.cache != nil { if cached, ok := h.cache.Get(cacheKey); ok { + if headers.CheckNotModified(w, r, cached) { + return + } h.serveFromCache(w, r, cacheKey, cached) return } @@ -71,46 +78,54 @@ func (h *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Cache miss — determine whether this is a directory request (needs index // resolution) only now, when we actually need to hit the filesystem. - resolvedPath, canonicalURL := h.resolveIndexPath(absPath, urlPath) + resolvedPath, canonicalURL, info, statErr, serveDirList := h.resolveIndexPath(absPath, urlPath) // If the path is a directory and directory listing is enabled, serve the // listing immediately (skip index resolution and the cache lookup below). - if resolvedPath == "" { + if serveDirList { h.serveDirectoryListing(w, r, absPath, urlPath) return } // Re-check cache with the canonical URL (e.g. "/subdir/index.html") in // case the directory-resolved key is cached even though the bare path isn't. - if h.cfg.Cache.Enabled && canonicalURL != cacheKey { + if h.cfg.Cache.Enabled && h.cache != nil && canonicalURL != cacheKey { if cached, ok := h.cache.Get(canonicalURL); ok { + if headers.CheckNotModified(w, r, cached) { + return + } h.serveFromCache(w, r, canonicalURL, cached) return } } // True cache miss — read from disk. - h.serveFromDisk(w, r, resolvedPath, canonicalURL) + h.serveFromDisk(w, r, resolvedPath, canonicalURL, info, statErr) } -// resolveIndexPath maps a directory path to its index file. -// Returns ("", "") when the path is a directory and directory listing is -// enabled — the caller should invoke serveDirectoryListing instead. -// Returns the resolved absolute path and the canonical URL key otherwise. -func (h *FileHandler) resolveIndexPath(absPath, urlPath string) (string, string) { +// resolveIndexPath maps a directory path to its index file and reuses stat +// results so the caller can avoid a second os.Stat on the cold-miss path. +// When directory listing is enabled for a directory path it returns +// serveDirList=true and the caller should invoke serveDirectoryListing. +func (h *FileHandler) resolveIndexPath(absPath, urlPath string) (resolvedPath, canonicalURL string, info os.FileInfo, statErr error, serveDirList bool) { info, err := os.Stat(absPath) - if err == nil && info.IsDir() { + if err != nil { + return absPath, urlPath, nil, err, false + } + if info.IsDir() { // Directory listing takes precedence over index resolution when enabled. if h.cfg.Security.DirectoryListing { - return "", "" + return "", "", info, nil, true } indexFile := h.cfg.Files.Index if indexFile == "" { indexFile = "index.html" } - return filepath.Join(absPath, indexFile), strings.TrimRight(urlPath, "/") + "/" + indexFile + indexPath := filepath.Join(absPath, indexFile) + indexInfo, indexErr := os.Stat(indexPath) + return indexPath, strings.TrimRight(urlPath, "/") + "/" + indexFile, indexInfo, indexErr, false } - return absPath, urlPath + return absPath, urlPath, info, nil, false } // serveFromCache writes a cached file to the response, respecting Accept-Encoding. @@ -160,10 +175,9 @@ func (h *FileHandler) negotiateEncoding(r *http.Request, f *cache.CachedFile) ([ // serveFromDisk reads the file from disk, populates the cache, and serves it. // If the file does not exist on disk, it falls back to the embedded default // assets (index.html, 404.html, style.css) before returning a 404. -func (h *FileHandler) serveFromDisk(w http.ResponseWriter, r *http.Request, absPath, urlPath string) { - info, err := os.Stat(absPath) - if err != nil { - if os.IsNotExist(err) { +func (h *FileHandler) serveFromDisk(w http.ResponseWriter, r *http.Request, absPath, urlPath string, info os.FileInfo, statErr error) { + if statErr != nil { + if os.IsNotExist(statErr) { // Try the embedded fallback assets before giving up. if h.serveEmbedded(w, r, urlPath) { return @@ -171,7 +185,7 @@ func (h *FileHandler) serveFromDisk(w http.ResponseWriter, r *http.Request, absP h.serveNotFound(w, r) return } - if os.IsPermission(err) { + if os.IsPermission(statErr) { log.Printf("handler: permission denied accessing %q", absPath) http.Error(w, "Forbidden", http.StatusForbidden) return @@ -211,8 +225,10 @@ func (h *FileHandler) serveFromDisk(w http.ResponseWriter, r *http.Request, absP Size: info.Size(), } - // Load pre-compressed sidecar files if enabled. - if h.cfg.Compression.Enabled && h.cfg.Compression.Precompressed { + // Load pre-compressed sidecar files only for files that are actually + // compressible and large enough to benefit from compression. + if h.cfg.Compression.Enabled && h.cfg.Compression.Precompressed && + compress.IsCompressible(ct) && len(data) >= h.cfg.Compression.MinSize { cached.GzipData = loadSidecar(absPath + ".gz") cached.BrData = loadSidecar(absPath + ".br") } @@ -226,7 +242,7 @@ func (h *FileHandler) serveFromDisk(w http.ResponseWriter, r *http.Request, absP } // Store in cache. - if h.cfg.Cache.Enabled { + if h.cfg.Cache.Enabled && h.cache != nil { h.cache.Put(urlPath, cached) } @@ -288,16 +304,11 @@ func (h *FileHandler) serveEmbedded(w http.ResponseWriter, r *http.Request, urlP // The configured path is validated via PathSafe to prevent path traversal through // a malicious config value (e.g. STATIC_FILES_NOT_FOUND=../../etc/passwd). func (h *FileHandler) serveNotFound(w http.ResponseWriter, r *http.Request) { - if h.cfg.Files.NotFound != "" { - safeNotFound, err := security.PathSafe(h.cfg.Files.NotFound, h.cfg.Files.Root, false) - if err == nil { - if data, err := os.ReadFile(safeNotFound); err == nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusNotFound) - w.Write(data) - return - } - } + if h.notFoundData != nil { + w.Header().Set("Content-Type", h.notFoundContentType) + w.WriteHeader(http.StatusNotFound) + w.Write(h.notFoundData) + return } // Fall back to the embedded default 404.html. @@ -311,6 +322,25 @@ func (h *FileHandler) serveNotFound(w http.ResponseWriter, r *http.Request) { http.Error(w, "404 Not Found", http.StatusNotFound) } +func (h *FileHandler) loadCustomNotFoundPage() ([]byte, string) { + if h.cfg.Files.NotFound == "" { + return nil, "" + } + safeNotFound, err := security.PathSafe(h.cfg.Files.NotFound, h.absRoot, false) + if err != nil { + return nil, "" + } + data, err := os.ReadFile(safeNotFound) + if err != nil { + return nil, "" + } + ct := detectContentType(safeNotFound, data) + if ct == "application/octet-stream" { + ct = "text/html; charset=utf-8" + } + return data, ct +} + // handleSecurityError maps security sentinel errors to HTTP responses. func (h *FileHandler) handleSecurityError(w http.ResponseWriter, err error) { switch { diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index 002b87c..8fa72b8 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -444,7 +444,7 @@ func TestBuildHandler_CacheDisabled(t *testing.T) { cfg := makeCfgWithRoot(t, root) cfg.Cache.Enabled = false - c := cache.NewCache(cfg.Cache.MaxBytes) + var c *cache.Cache h := handler.BuildHandler(cfg, c) for i := range 3 { @@ -456,8 +456,8 @@ func TestBuildHandler_CacheDisabled(t *testing.T) { } } // No entries should appear in the cache. - if c.Stats().EntryCount != 0 { - t.Errorf("EntryCount = %d, want 0 when cache disabled", c.Stats().EntryCount) + if c != nil { + t.Fatal("cache should be nil when cache is disabled") } } @@ -824,3 +824,39 @@ func BenchmarkHandler_CacheHitGzip(b *testing.B) { } }) } + +// BenchmarkHandler_CacheHitQuiet measures the cache-hit path with request logging disabled. +func BenchmarkHandler_CacheHitQuiet(b *testing.B) { + log.SetOutput(io.Discard) + b.Cleanup(func() { log.SetOutput(os.Stderr) }) + + root := b.TempDir() + content := strings.Repeat("body { margin: 0; } ", 50) + if err := os.WriteFile(filepath.Join(root, "bench.css"), []byte(content), 0644); err != nil { + b.Fatal(err) + } + + cfg := &config.Config{} + cfg.Files.Root = root + cfg.Files.Index = "index.html" + cfg.Cache.Enabled = true + cfg.Cache.MaxBytes = 64 * 1024 * 1024 + cfg.Cache.MaxFileSize = 10 * 1024 * 1024 + cfg.Compression.Enabled = false + cfg.Security.BlockDotfiles = true + cfg.Headers.StaticMaxAge = 3600 + + c := cache.NewCache(cfg.Cache.MaxBytes) + h := handler.BuildHandlerQuiet(cfg, c) + + warmReq := httptest.NewRequest(http.MethodGet, "/bench.css", nil) + h.ServeHTTP(httptest.NewRecorder(), warmReq) + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodGet, "/bench.css", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } +} diff --git a/internal/handler/middleware.go b/internal/handler/middleware.go index e5fd258..d646693 100644 --- a/internal/handler/middleware.go +++ b/internal/handler/middleware.go @@ -1,24 +1,23 @@ package handler import ( - "io" "log" "net/http" "runtime/debug" + "strconv" "sync" "time" "github.com/BackendStack21/static-web/internal/cache" "github.com/BackendStack21/static-web/internal/compress" "github.com/BackendStack21/static-web/internal/config" - "github.com/BackendStack21/static-web/internal/headers" "github.com/BackendStack21/static-web/internal/security" ) // BuildHandler composes the full middleware chain and returns a ready-to-use // http.Handler. The chain is (outer to inner): // -// recovery → logging → security → headers (304 check) → compress → file handler +// recovery → logging → security → compress → file handler func BuildHandler(cfg *config.Config, c *cache.Cache) http.Handler { return buildHandlerWithLogger(cfg, c, false) } @@ -37,19 +36,14 @@ func buildHandlerWithLogger(cfg *config.Config, c *cache.Cache, quiet bool) http // Compression middleware (on-the-fly gzip for uncached/large files). compressed := compress.Middleware(&cfg.Compression, fileHandler) - // Headers middleware: 304 checks + cache-control for cached files. - withHeaders := headers.Middleware(c, &cfg.Headers, cfg.Files.Index, compressed) - // Security middleware: path validation + security headers. - withSecurity := security.Middleware(&cfg.Security, cfg.Files.Root, withHeaders) + withSecurity := security.Middleware(&cfg.Security, cfg.Files.Root, compressed) // Request logging (suppressed when quiet=true). - var withLogging http.Handler if quiet { - withLogging = loggingMiddlewareWithWriter(withSecurity, io.Discard) - } else { - withLogging = loggingMiddleware(withSecurity) + return recoveryMiddleware(withSecurity) } + withLogging := loggingMiddleware(withSecurity) // Panic recovery (outermost). withRecovery := recoveryMiddleware(withLogging) @@ -90,18 +84,11 @@ var srwPool = sync.Pool{ // loggingMiddleware logs each request with method, path, status, duration, and bytes // using the standard logger. func loggingMiddleware(next http.Handler) http.Handler { - return loggingMiddlewareWithWriter(next, nil) + return loggingMiddlewareWithWriter(next, log.Default()) } -// loggingMiddlewareWithWriter is like loggingMiddleware but writes to w. -// When w is io.Discard (or any writer that discards output), access logging is -// effectively suppressed. When w is nil, the standard logger is used. -func loggingMiddlewareWithWriter(next http.Handler, w io.Writer) http.Handler { - var logger *log.Logger - if w != nil { - logger = log.New(w, "", log.LstdFlags) - } - +// loggingMiddlewareWithWriter is like loggingMiddleware but writes to the provided logger. +func loggingMiddlewareWithWriter(next http.Handler, logger *log.Logger) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { start := time.Now() srw := srwPool.Get().(*statusResponseWriter) @@ -110,28 +97,26 @@ func loggingMiddlewareWithWriter(next http.Handler, w io.Writer) http.Handler { srw.size = 0 next.ServeHTTP(srw, r) duration := time.Since(start) - if logger != nil { - logger.Printf("%s %s %d %d %s", - r.Method, - r.URL.RequestURI(), - srw.status, - srw.size, - duration.Round(time.Microsecond), - ) - } else { - log.Printf("%s %s %d %d %s", - r.Method, - r.URL.RequestURI(), - srw.status, - srw.size, - duration.Round(time.Microsecond), - ) - } + logger.Print(formatAccessLogLine(r.Method, r.URL.RequestURI(), srw.status, srw.size, duration)) srw.ResponseWriter = nil // release reference before returning to pool srwPool.Put(srw) }) } +func formatAccessLogLine(method, uri string, status int, size int64, duration time.Duration) string { + buf := make([]byte, 0, len(method)+len(uri)+48) + buf = append(buf, method...) + buf = append(buf, ' ') + buf = append(buf, uri...) + buf = append(buf, ' ') + buf = strconv.AppendInt(buf, int64(status), 10) + buf = append(buf, ' ') + buf = strconv.AppendInt(buf, size, 10) + buf = append(buf, ' ') + buf = append(buf, duration.Round(time.Microsecond).String()...) + return string(buf) +} + // recoveryMiddleware catches panics in the handler chain and returns a 500. // It logs the panic value and the full stack trace. func recoveryMiddleware(next http.Handler) http.Handler { diff --git a/internal/headers/headers.go b/internal/headers/headers.go index ba6d583..880252c 100644 --- a/internal/headers/headers.go +++ b/internal/headers/headers.go @@ -1,6 +1,4 @@ -// Package headers provides HTTP caching and response header middleware. -// It handles ETag/Last-Modified conditional requests (304 Not Modified) and -// sets appropriate Cache-Control headers based on file type. +// Package headers provides HTTP caching utilities and response header helpers. package headers import ( @@ -14,67 +12,32 @@ import ( "github.com/BackendStack21/static-web/internal/config" ) -// Middleware returns an http.Handler that: -// 1. Looks up the requested file in the cache to obtain ETag and LastModified. -// 2. Handles If-None-Match → 304 Not Modified. -// 3. Handles If-Modified-Since → 304 Not Modified. -// 4. Sets Cache-Control, ETag, Last-Modified, and Vary headers. -// -// If the file is not yet cached (cache miss at this stage), header setting is -// deferred — the file handler will populate the cache and the next request -// will receive full caching headers. -func Middleware(c *cache.Cache, cfg *config.HeadersConfig, indexFile string, next http.Handler) http.Handler { +// CacheKeyForPath normalises a URL path to the cache key used by the file +// handler. Directory paths (trailing slash, or bare "/") are mapped to their +// index file so that 304 checks succeed for index requests. +func CacheKeyForPath(urlPath, indexFile string) string { if indexFile == "" { indexFile = "index.html" } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Only handle GET and HEAD. - if r.Method != http.MethodGet && r.Method != http.MethodHead { - next.ServeHTTP(w, r) - return - } - - urlPath := cacheKeyForPath(r.URL.Path, indexFile) - - cached, ok := c.Get(urlPath) - if ok { - // Check conditional request headers. - if checkNotModified(w, r, cached) { - return - } - // Set caching headers. - setCacheHeaders(w, urlPath, cached, cfg) - } - - next.ServeHTTP(w, r) - }) -} - -// cacheKeyForPath normalises a URL path to the cache key used by the file -// handler. Directory paths (trailing slash, or bare "/") are mapped to their -// index file so that 304 checks succeed for index requests. -func cacheKeyForPath(urlPath, indexFile string) string { if urlPath == "" || urlPath == "/" { return "/" + indexFile } - // Any path ending with "/" is a directory request → append index filename. if strings.HasSuffix(urlPath, "/") { return urlPath + indexFile } return urlPath } -// checkNotModified evaluates conditional request headers. +// CheckNotModified evaluates conditional request headers. // Returns true and writes a 304 response if the resource has not changed. -func checkNotModified(w http.ResponseWriter, r *http.Request, f *cache.CachedFile) bool { +func CheckNotModified(w http.ResponseWriter, r *http.Request, f *cache.CachedFile) bool { etag := f.ETagFull if etag == "" { etag = `W/"` + f.ETag + `"` } - // If-None-Match takes precedence over If-Modified-Since (RFC 7232 §6). if inm := r.Header.Get("If-None-Match"); inm != "" { - if etagMatches(inm, etag) { + if ETagMatches(inm, etag) { w.Header().Set("ETag", etag) w.WriteHeader(http.StatusNotModified) return true @@ -82,10 +45,8 @@ func checkNotModified(w http.ResponseWriter, r *http.Request, f *cache.CachedFil return false } - // If-Modified-Since. if ims := r.Header.Get("If-Modified-Since"); ims != "" { if t, err := http.ParseTime(ims); err == nil { - // Truncate to second precision for comparison. if !f.LastModified.After(t.Add(time.Second - 1)) { w.Header().Set("Last-Modified", f.LastModified.UTC().Format(http.TimeFormat)) w.WriteHeader(http.StatusNotModified) @@ -97,9 +58,9 @@ func checkNotModified(w http.ResponseWriter, r *http.Request, f *cache.CachedFil return false } -// etagMatches reports whether the If-None-Match value matches the given etag. +// ETagMatches reports whether the If-None-Match value matches the given etag. // It supports the wildcard "*" and a comma-separated list of tags. -func etagMatches(ifNoneMatch, etag string) bool { +func ETagMatches(ifNoneMatch, etag string) bool { if strings.TrimSpace(ifNoneMatch) == "*" { return true } @@ -111,8 +72,8 @@ func etagMatches(ifNoneMatch, etag string) bool { return false } -// setCacheHeaders writes ETag, Last-Modified, Cache-Control, and Vary headers. -func setCacheHeaders(w http.ResponseWriter, urlPath string, f *cache.CachedFile, cfg *config.HeadersConfig) { +// SetCacheHeaders writes ETag, Last-Modified, Cache-Control, and Vary headers. +func SetCacheHeaders(w http.ResponseWriter, urlPath string, f *cache.CachedFile, cfg *config.HeadersConfig) { etag := f.ETagFull if etag == "" { etag = `W/"` + f.ETag + `"` @@ -126,7 +87,6 @@ func setCacheHeaders(w http.ResponseWriter, urlPath string, f *cache.CachedFile, w.Header().Set("Cache-Control", "no-cache") } else { cc := "public, max-age=" + strconv.Itoa(maxAge) - // Immutable hint for fingerprinted assets. if cfg.ImmutablePattern != "" && matchesImmutablePattern(urlPath, cfg.ImmutablePattern) { cc += ", immutable" } @@ -134,16 +94,18 @@ func setCacheHeaders(w http.ResponseWriter, urlPath string, f *cache.CachedFile, } } -// cacheMaxAge returns the appropriate max-age for the file. +// SetFileHeaders writes caching headers for a file response. +func SetFileHeaders(w http.ResponseWriter, urlPath string, f *cache.CachedFile, cfg *config.HeadersConfig) { + SetCacheHeaders(w, urlPath, f, cfg) +} + func cacheMaxAge(urlPath, contentType string, cfg *config.HeadersConfig) int { - // HTML gets its own max-age (often 0 for always-revalidate). if isHTML(urlPath, contentType) { return cfg.HTMLMaxAge } return cfg.StaticMaxAge } -// isHTML reports whether the path or content type indicates an HTML file. func isHTML(urlPath, contentType string) bool { if strings.Contains(contentType, "text/html") { return true @@ -152,7 +114,6 @@ func isHTML(urlPath, contentType string) bool { return ext == ".html" || ext == ".htm" } -// matchesImmutablePattern checks whether the file path matches the immutable glob pattern. func matchesImmutablePattern(urlPath, pattern string) bool { base := filepath.Base(urlPath) matched, err := filepath.Match(pattern, base) @@ -161,10 +122,3 @@ func matchesImmutablePattern(urlPath, pattern string) bool { } return matched } - -// SetFileHeaders writes ETag, Last-Modified, Cache-Control, and Vary response -// headers for a file that has just been loaded (possibly bypassing the middleware -// cache-check path). This is called directly from the file handler. -func SetFileHeaders(w http.ResponseWriter, urlPath string, f *cache.CachedFile, cfg *config.HeadersConfig) { - setCacheHeaders(w, urlPath, f, cfg) -} diff --git a/internal/headers/headers_test.go b/internal/headers/headers_test.go index 1162dc5..47da649 100644 --- a/internal/headers/headers_test.go +++ b/internal/headers/headers_test.go @@ -12,379 +12,117 @@ import ( "github.com/BackendStack21/static-web/internal/headers" ) -func makeCache(path string, data []byte, ct string) *cache.Cache { - c := cache.NewCache(10 * 1024 * 1024) - f := &cache.CachedFile{ +func makeCachedFile(data []byte, ct string) *cache.CachedFile { + return &cache.CachedFile{ Data: data, ETag: "abcdef1234567890", + ETagFull: `W/"abcdef1234567890"`, LastModified: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), ContentType: ct, Size: int64(len(data)), } - c.Put(path, f) - return c } -func TestMiddleware_SetsETagHeader(t *testing.T) { - c := makeCache("/style.css", []byte("body{}"), "text/css") - cfg := &config.HeadersConfig{StaticMaxAge: 3600, HTMLMaxAge: 0} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/style.css", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - etag := rr.Header().Get("ETag") - if etag == "" { - t.Error("ETag header should be set") +func TestCacheKeyForPath(t *testing.T) { + tests := []struct { + name string + urlPath string + indexFile string + want string + }{ + {name: "root", urlPath: "/", indexFile: "index.html", want: "/index.html"}, + {name: "directory", urlPath: "/docs/", indexFile: "home.html", want: "/docs/home.html"}, + {name: "file", urlPath: "/app.js", indexFile: "index.html", want: "/app.js"}, + {name: "default index", urlPath: "/", indexFile: "", want: "/index.html"}, } - if etag != `W/"abcdef1234567890"` { - t.Errorf("ETag = %q, want W/\"abcdef1234567890\"", etag) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := headers.CacheKeyForPath(tt.urlPath, tt.indexFile); got != tt.want { + t.Fatalf("CacheKeyForPath(%q, %q) = %q, want %q", tt.urlPath, tt.indexFile, got, tt.want) + } + }) } } -func TestMiddleware_304_IfNoneMatch(t *testing.T) { - c := makeCache("/app.js", []byte("console.log(1)"), "application/javascript") - cfg := &config.HeadersConfig{StaticMaxAge: 3600} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) +func TestCheckNotModifiedIfNoneMatch(t *testing.T) { + f := makeCachedFile([]byte("console.log(1)"), "application/javascript") req := httptest.NewRequest(http.MethodGet, "/app.js", nil) req.Header.Set("If-None-Match", `W/"abcdef1234567890"`) rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + if !headers.CheckNotModified(rr, req, f) { + t.Fatal("CheckNotModified returned false, want true") + } if rr.Code != http.StatusNotModified { - t.Errorf("status = %d, want 304", rr.Code) + t.Fatalf("status = %d, want 304", rr.Code) } } -func TestMiddleware_304_IfModifiedSince(t *testing.T) { - c := makeCache("/page.html", []byte(""), "text/html") - cfg := &config.HeadersConfig{HTMLMaxAge: 0} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) +func TestCheckNotModifiedIfModifiedSince(t *testing.T) { + f := makeCachedFile([]byte(""), "text/html") req := httptest.NewRequest(http.MethodGet, "/page.html", nil) - // Send a time after LastModified to trigger 304. req.Header.Set("If-Modified-Since", time.Date(2024, 1, 16, 0, 0, 0, 0, time.UTC).Format(http.TimeFormat)) rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + if !headers.CheckNotModified(rr, req, f) { + t.Fatal("CheckNotModified returned false, want true") + } if rr.Code != http.StatusNotModified { - t.Errorf("status = %d, want 304", rr.Code) + t.Fatalf("status = %d, want 304", rr.Code) } } -func TestMiddleware_200_ETagMismatch(t *testing.T) { - c := makeCache("/data.json", []byte(`{}`), "application/json") - cfg := &config.HeadersConfig{StaticMaxAge: 3600} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) +func TestCheckNotModifiedReturnsFalseOnMismatch(t *testing.T) { + f := makeCachedFile([]byte(`{}`), "application/json") req := httptest.NewRequest(http.MethodGet, "/data.json", nil) req.Header.Set("If-None-Match", `W/"differentetag0000"`) rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Errorf("status = %d, want 200 on ETag mismatch", rr.Code) + if headers.CheckNotModified(rr, req, f) { + t.Fatal("CheckNotModified returned true, want false") } } -func TestMiddleware_CacheControlHTML(t *testing.T) { - c := makeCache("/index.html", []byte(""), "text/html") +func TestSetCacheHeadersHTML(t *testing.T) { + f := makeCachedFile([]byte(""), "text/html") cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/index.html", nil) rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - cc := rr.Header().Get("Cache-Control") - if cc != "no-cache" { - t.Errorf("Cache-Control = %q, want no-cache for HTML with MaxAge=0", cc) - } -} - -func TestMiddleware_CacheControlStatic(t *testing.T) { - c := makeCache("/logo.png", []byte("PNG"), "image/png") - cfg := &config.HeadersConfig{StaticMaxAge: 86400} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/logo.png", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - cc := rr.Header().Get("Cache-Control") - if cc != "public, max-age=86400" { - t.Errorf("Cache-Control = %q, want public, max-age=86400", cc) - } -} - -func TestMiddleware_VaryHeader(t *testing.T) { - c := makeCache("/main.css", []byte("h1{}"), "text/css") - cfg := &config.HeadersConfig{StaticMaxAge: 3600} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/main.css", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if vary := rr.Header().Get("Vary"); vary == "" { - t.Error("Vary header should be set") - } -} - -func TestMiddleware_PassthroughOnCacheMiss(t *testing.T) { - c := cache.NewCache(1024) - cfg := &config.HeadersConfig{StaticMaxAge: 3600} - called := false - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/notcached.txt", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if !called { - t.Error("next should be called on cache miss") - } - if rr.Code != http.StatusOK { - t.Errorf("status = %d, want 200", rr.Code) - } -} - -func TestMiddleware_WildcardIfNoneMatch(t *testing.T) { - c := makeCache("/wild.js", []byte("x=1"), "application/javascript") - cfg := &config.HeadersConfig{StaticMaxAge: 3600} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/wild.js", nil) - req.Header.Set("If-None-Match", "*") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if rr.Code != http.StatusNotModified { - t.Errorf("status = %d, want 304 for If-None-Match: *", rr.Code) - } -} - -// --------------------------------------------------------------------------- -// Additional headers coverage -// --------------------------------------------------------------------------- - -// TestMiddleware_PostMethodPassthrough verifies non-GET/HEAD methods bypass the -// conditional-request logic entirely. -func TestMiddleware_PostMethodPassthrough(t *testing.T) { - c := makeCache("/api.json", []byte(`{}`), "application/json") - cfg := &config.HeadersConfig{StaticMaxAge: 3600} - - called := false - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - w.WriteHeader(http.StatusOK) - }) - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodPost, "/api.json", nil) - req.Header.Set("If-None-Match", `W/"abcdef1234567890"`) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + headers.SetCacheHeaders(rr, "/index.html", f, cfg) - if !called { - t.Error("next should be called for non-GET/HEAD methods") - } - if rr.Code != http.StatusOK { - t.Errorf("status = %d, want 200 for POST passthrough", rr.Code) + if etag := rr.Header().Get("ETag"); etag != `W/"abcdef1234567890"` { + t.Fatalf("ETag = %q, want W/\"abcdef1234567890\"", etag) } -} - -// TestMiddleware_RootPathResolvesToIndexHTML verifies that "/" is mapped to -// "/index.html" for cache look-up purposes. -func TestMiddleware_RootPathResolvesToIndexHTML(t *testing.T) { - c := cache.NewCache(10 * 1024 * 1024) - indexFile := &cache.CachedFile{ - Data: []byte("Index"), - ETag: "indexetag1234567", - LastModified: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), - ContentType: "text/html", - Size: 18, + if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" { + t.Fatalf("Cache-Control = %q, want no-cache", cc) } - // Populate cache under the canonical "/index.html" key. - c.Put("/index.html", indexFile) - - cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/", nil) - // If the middleware correctly maps "/" → "/index.html", the ETag should be set. - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if etag := rr.Header().Get("ETag"); etag == "" { - t.Error("ETag should be set when / maps to cached /index.html") + if vary := rr.Header().Get("Vary"); vary == "" { + t.Fatal("Vary header should be set") } } -// TestMiddleware_ImmutablePattern verifies that a matching glob pattern adds the -// "immutable" directive to Cache-Control. -func TestMiddleware_ImmutablePattern(t *testing.T) { - c := makeCache("/assets/app.abc123.js", []byte("console.log(1)"), "application/javascript") - cfg := &config.HeadersConfig{ - StaticMaxAge: 31536000, - ImmutablePattern: "*.js", - } - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/assets/app.abc123.js", nil) +func TestSetCacheHeadersStaticImmutable(t *testing.T) { + f := makeCachedFile([]byte("console.log(1)"), "application/javascript") + cfg := &config.HeadersConfig{StaticMaxAge: 31536000, ImmutablePattern: "*.js"} rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - cc := rr.Header().Get("Cache-Control") - if !strings.Contains(cc, "immutable") { - t.Errorf("Cache-Control = %q, want it to contain 'immutable' for matched pattern", cc) - } - if !strings.Contains(cc, "public") { - t.Errorf("Cache-Control = %q, want it to contain 'public'", cc) - } -} - -// TestMiddleware_ImmutablePatternNoMatch verifies that a non-matching glob does NOT -// add the "immutable" directive. -func TestMiddleware_ImmutablePatternNoMatch(t *testing.T) { - c := makeCache("/assets/image.png", []byte("PNG"), "image/png") - cfg := &config.HeadersConfig{ - StaticMaxAge: 3600, - ImmutablePattern: "*.js", // pattern targets .js only - } - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/assets/image.png", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + headers.SetCacheHeaders(rr, "/assets/app.abc123.js", f, cfg) cc := rr.Header().Get("Cache-Control") - if strings.Contains(cc, "immutable") { - t.Errorf("Cache-Control = %q, must NOT contain 'immutable' for non-matching pattern", cc) + if !strings.Contains(cc, "public") || !strings.Contains(cc, "immutable") { + t.Fatalf("Cache-Control = %q, want public + immutable", cc) } } -// TestSetFileHeaders verifies that SetFileHeaders (called from the file handler) -// sets the same caching headers as the middleware path. -func TestSetFileHeaders(t *testing.T) { - f := &cache.CachedFile{ - Data: []byte("body { color: red }"), - ETag: "deadbeef01234567", - LastModified: time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC), - ContentType: "text/css", - Size: 19, +func TestETagMatches(t *testing.T) { + if !headers.ETagMatches("*", `W/"abc"`) { + t.Fatal("ETagMatches wildcard = false, want true") } - cfg := &config.HeadersConfig{StaticMaxAge: 7200} - - rr := httptest.NewRecorder() - headers.SetFileHeaders(rr, "/theme.css", f, cfg) - - if etag := rr.Header().Get("ETag"); etag != `W/"deadbeef01234567"` { - t.Errorf("ETag = %q, want W/\"deadbeef01234567\"", etag) - } - if lm := rr.Header().Get("Last-Modified"); lm == "" { - t.Error("Last-Modified should be set by SetFileHeaders") - } - if cc := rr.Header().Get("Cache-Control"); cc != "public, max-age=7200" { - t.Errorf("Cache-Control = %q, want public, max-age=7200", cc) - } -} - -// TestMiddleware_IfModifiedSince_ResourceNewer verifies 200 is returned when the -// resource has been modified after the If-Modified-Since date. -func TestMiddleware_IfModifiedSince_ResourceNewer(t *testing.T) { - c := makeCache("/newer.html", []byte("fresh"), "text/html") - cfg := &config.HeadersConfig{HTMLMaxAge: 0} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/newer.html", nil) - // Cached LastModified is 2024-01-15; send an IMS of 2024-01-10 (older). - req.Header.Set("If-Modified-Since", time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC).Format(http.TimeFormat)) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Errorf("status = %d, want 200 when resource is newer than IMS", rr.Code) - } -} - -// TestMiddleware_InvalidIfModifiedSince verifies that an unparseable IMS header -// is ignored and the resource is served normally. -func TestMiddleware_InvalidIfModifiedSince(t *testing.T) { - c := makeCache("/page.html", []byte(""), "text/html") - cfg := &config.HeadersConfig{HTMLMaxAge: 0} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/page.html", nil) - req.Header.Set("If-Modified-Since", "not-a-valid-date") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Errorf("status = %d, want 200 for invalid IMS date", rr.Code) + if !headers.ETagMatches(`W/"abc", W/"def"`, `W/"def"`) { + t.Fatal("ETagMatches list = false, want true") } -} - -// TestMiddleware_HTMLExtension verifies .html files get HTMLMaxAge Cache-Control. -func TestMiddleware_HTMLExtension(t *testing.T) { - c := makeCache("/about.html", []byte("About"), "text/html") - cfg := &config.HeadersConfig{HTMLMaxAge: 300, StaticMaxAge: 86400} - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := headers.Middleware(c, cfg, "index.html", next) - req := httptest.NewRequest(http.MethodGet, "/about.html", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - cc := rr.Header().Get("Cache-Control") - if cc != "public, max-age=300" { - t.Errorf("Cache-Control = %q, want public, max-age=300 for HTML with HTMLMaxAge=300", cc) + if headers.ETagMatches(`W/"abc"`, `W/"def"`) { + t.Fatal("ETagMatches mismatch = true, want false") } } From 9ae60e29b716c285799e293f54d8a239a0b02dd2 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 8 Mar 2026 08:55:51 +0100 Subject: [PATCH 04/17] ci(security): pin GitHub Actions and add govulncheck --- .github/workflows/ci.yml | 10 ++++++++-- .github/workflows/release.yml | 6 +++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97d039a..93177e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod cache: true @@ -28,3 +28,9 @@ jobs: - name: Test run: go test -race ./... + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01bf857..f95585d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod cache: true @@ -28,7 +28,7 @@ jobs: run: go test -race ./... - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 with: distribution: goreleaser version: "~> v2" From 9326f634b9940a2e655a8d196b6c29d27c35fee1 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 8 Mar 2026 09:13:43 +0100 Subject: [PATCH 05/17] build(benchmark): fix bun static target and lower default load --- benchmark/bench.sh | 8 ++++---- benchmark/docker-compose.benchmark.yml | 5 +++-- public/index.html | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/benchmark/bench.sh b/benchmark/bench.sh index 8a6674b..aca09e1 100755 --- a/benchmark/bench.sh +++ b/benchmark/bench.sh @@ -10,8 +10,8 @@ # ./benchmark/bench.sh [OPTIONS] # # Options: -# -c Connections (default: 125) -# -n Total requests (default: 500000) +# -c Connections (default: 50) +# -n Total requests (default: 100000) # -d Duration in seconds — overrides -n when set # -k Keep containers running after benchmark (default: tear down) # -h Show this help @@ -28,8 +28,8 @@ COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.benchmark.yml" RESULTS_DIR="${SCRIPT_DIR}/results" # ---------- defaults --------------------------------------------------------- -CONNECTIONS=125 -REQUESTS=500000 +CONNECTIONS=50 +REQUESTS=100000 DURATION="" # empty = use -n mode; set seconds e.g. 30 to use -d mode KEEP=false diff --git a/benchmark/docker-compose.benchmark.yml b/benchmark/docker-compose.benchmark.yml index 423419d..a482737 100644 --- a/benchmark/docker-compose.benchmark.yml +++ b/benchmark/docker-compose.benchmark.yml @@ -46,7 +46,7 @@ services: restart: unless-stopped # ------------------------------------------------------------------------- - # bun — native HTML static server (bun ./index.html) + # bun — native HTML static server # ------------------------------------------------------------------------- bun: image: oven/bun:alpine @@ -54,7 +54,8 @@ services: - "8003:3000" volumes: - ../public:/www:ro - command: ["bun", "/www/index.html", "--port=3000", "--host=0.0.0.0"] + working_dir: /www + command: ["bun", "./index.html", "--port=3000", "--host=0.0.0.0"] restart: unless-stopped # ------------------------------------------------------------------------- diff --git a/public/index.html b/public/index.html index 2d1891d..26fbd3b 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ static-web - + From 98e782ac0d7525c9bba44b0326e2b37c38818439 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 8 Mar 2026 10:00:41 +0100 Subject: [PATCH 06/17] perf(server): add startup preloading, zero-alloc fast path, path cache, and GC tuning - Add cache.Preload() to read all eligible files into LRU at startup - Replace http.ServeContent with direct w.Write() for non-Range cache hits - Pre-format Content-Type and Content-Length header slices on CachedFile - Add sync.Map-based PathCache to eliminate per-request EvalSymlinks - Pre-warm path cache from preloaded file paths - Add gc_percent config option to tune Go runtime GOGC - Add --preload, --gc-percent, --benchmark-mode CLI flags - Add STATIC_CACHE_PRELOAD and STATIC_CACHE_GC_PERCENT env vars - SIGHUP now flushes both file cache and path-safety cache - Add minimal benchmark handler for isolated throughput testing Production mode with preload + GC 400 reaches ~137k req/sec on Apple M2 Pro, beating Bun native static serve (~129k) by ~6%. --- cmd/static-web/config.toml.example | 12 ++ cmd/static-web/main.go | 122 ++++++++++++++-- internal/cache/cache.go | 18 +++ internal/cache/preload.go | 215 +++++++++++++++++++++++++++++ internal/config/config.go | 18 +++ internal/handler/file.go | 75 ++++++++-- internal/handler/middleware.go | 23 ++- internal/handler/minimal.go | 127 +++++++++++++++++ internal/handler/minimal_test.go | 190 +++++++++++++++++++++++++ internal/security/security.go | 117 ++++++++++++++-- internal/server/signals.go | 20 ++- 11 files changed, 895 insertions(+), 42 deletions(-) create mode 100644 internal/cache/preload.go create mode 100644 internal/handler/minimal.go create mode 100644 internal/handler/minimal_test.go diff --git a/cmd/static-web/config.toml.example b/cmd/static-web/config.toml.example index be83f49..3eb5d02 100644 --- a/cmd/static-web/config.toml.example +++ b/cmd/static-web/config.toml.example @@ -43,6 +43,13 @@ not_found = "404.html" # Enable or disable the in-memory LRU file cache. enabled = true +# Preload all files into cache at startup for maximum throughput. +# When enabled, every file under [files.root] that is smaller than max_file_size +# is read into RAM when the server starts. This eliminates filesystem I/O on +# first requests and can roughly double throughput for cache-hit workloads. +# Default: false. Recommended for deployments with a bounded set of static files. +preload = false + # Maximum total bytes to store in cache (default 256 MB). max_bytes = 268435456 @@ -52,6 +59,11 @@ max_file_size = 10485760 # Optional TTL for cache entries. 0 means no expiry (flush with SIGHUP). ttl = "0s" +# Go runtime garbage collector target percentage. A higher value reduces GC +# frequency, trading memory for throughput. 0 means "do not change" (Go +# default is 100). Recommended: 400 for high-throughput preloaded deployments. +# gc_percent = 0 + [compression] # Enable gzip compression for compressible content types. enabled = true diff --git a/cmd/static-web/main.go b/cmd/static-web/main.go index 23abff0..3f85284 100644 --- a/cmd/static-web/main.go +++ b/cmd/static-web/main.go @@ -19,12 +19,15 @@ import ( "os" "path/filepath" "runtime" + "runtime/debug" "strconv" "strings" "github.com/BackendStack21/static-web/internal/cache" + "github.com/BackendStack21/static-web/internal/compress" "github.com/BackendStack21/static-web/internal/config" "github.com/BackendStack21/static-web/internal/handler" + "github.com/BackendStack21/static-web/internal/security" "github.com/BackendStack21/static-web/internal/server" "github.com/BackendStack21/static-web/internal/version" ) @@ -92,9 +95,12 @@ func runServe(args []string) { // Cache. noCache := fs.Bool("no-cache", false, "disable in-memory file cache") cacheSize := fs.String("cache-size", "", "max cache size, e.g. 256MB, 1GB (default: 256MB)") + preload := fs.Bool("preload", false, "preload all files into cache at startup for maximum throughput") + gcPercent := fs.Int("gc-percent", 0, "set Go GC target percentage (0=default, 400 recommended for high throughput)") // Compression. noCompress := fs.Bool("no-compress", false, "disable response compression") + benchmarkMode := fs.Bool("benchmark-mode", false, "serve files via a minimal benchmark-oriented handler") // Security. cors := fs.String("cors", "", "allowed CORS origins, comma-separated or * for all") @@ -117,6 +123,9 @@ func runServe(args []string) { effectivePort = *port } effectiveQuiet := *quiet || *quietLong + if *benchmarkMode { + effectiveQuiet = true + } // Load configuration (defaults → config file → env vars). cfg, err := config.Load(*cfgPath) @@ -142,7 +151,10 @@ func runServe(args []string) { notFound: *notFound, noCache: *noCache, cacheSize: *cacheSize, + preload: *preload, + gcPercent: *gcPercent, noCompress: *noCompress, + benchmarkMode: *benchmarkMode, cors: *cors, dirListing: *dirListing, noDotfileBlock: *noDotfileBlock, @@ -159,36 +171,84 @@ func runServe(args []string) { if !effectiveQuiet { log.Printf("static-web %s starting (addr=%s, root=%s)", version.Version, cfg.Server.Addr, cfg.Files.Root) } + if *benchmarkMode { + log.Printf("benchmark mode enabled: cache, compression, TLS redirect, custom 404, security middleware, and extra headers are bypassed") + // Reduce GC frequency — in benchmark mode the handler is + // allocation-free so there is very little garbage, but net/http + // internals still allocate per-request. A higher GOGC lets those + // short-lived objects be collected in bulk, cutting GC pause + // overhead by ~10%. + debug.SetGCPercent(400) + } else if cfg.Cache.GCPercent > 0 { + old := debug.SetGCPercent(cfg.Cache.GCPercent) + if !effectiveQuiet { + log.Printf("GC target set to %d%% (was %d%%)", cfg.Cache.GCPercent, old) + } + } // Initialise the in-memory file cache (respects cfg.Cache.Enabled). var c *cache.Cache - if cfg.Cache.Enabled { + if cfg.Cache.Enabled && !*benchmarkMode { c = cache.NewCache(cfg.Cache.MaxBytes, cfg.Cache.TTL) } else { c = nil } + // Preload files into cache at startup if requested. + var pathCache *security.PathCache + if c != nil && cfg.Cache.Preload { + pcfg := cache.PreloadConfig{ + MaxFileSize: cfg.Cache.MaxFileSize, + IndexFile: cfg.Files.Index, + BlockDotfiles: cfg.Security.BlockDotfiles, + CompressEnabled: cfg.Compression.Enabled, + CompressMinSize: cfg.Compression.MinSize, + CompressLevel: cfg.Compression.Level, + CompressFn: compress.GzipBytes, + } + stats := c.Preload(cfg.Files.Root, pcfg) + if !effectiveQuiet { + log.Printf("preloaded %d files (%s) into cache (%d skipped)", + stats.Files, formatByteSize(stats.Bytes), stats.Skipped) + } + + // Pre-warm the path cache with every URL key the file cache knows about. + pathCache = security.NewPathCache() + pathCache.PreWarm(stats.Paths, cfg.Files.Root, cfg.Security.BlockDotfiles) + if !effectiveQuiet { + log.Printf("path cache pre-warmed with %d entries", pathCache.Len()) + } + } + // Build the full middleware + handler chain. var h http.Handler - if effectiveQuiet { - h = handler.BuildHandlerQuiet(cfg, c) + if *benchmarkMode { + h = handler.BuildBenchmarkHandler(cfg) + } else if effectiveQuiet { + h = handler.BuildHandlerQuiet(cfg, c, pathCache) } else { - h = handler.BuildHandler(cfg, c) + h = handler.BuildHandler(cfg, c, pathCache) } // Create the HTTP/HTTPS server. - srv := server.New(&cfg.Server, &cfg.Security, h) + serverCfg := cfg.Server + if *benchmarkMode { + serverCfg.TLSCert = "" + serverCfg.TLSKey = "" + serverCfg.RedirectHost = "" + } + srv := server.New(&serverCfg, &cfg.Security, h) // Start listeners in the background. go func() { - if err := srv.Start(&cfg.Server); err != nil { + if err := srv.Start(&serverCfg); err != nil { log.Printf("server start error: %v", err) } }() // Block until SIGTERM/SIGINT, handling SIGHUP for live reload. ctx := context.Background() - server.RunSignalHandler(ctx, srv, c, *cfgPath, &cfg) + server.RunSignalHandler(ctx, srv, c, *cfgPath, &cfg, pathCache) } // flagOverrides groups all serve-subcommand CLI flags that can override config. @@ -203,7 +263,10 @@ type flagOverrides struct { notFound string noCache bool cacheSize string + preload bool + gcPercent int noCompress bool + benchmarkMode bool cors string dirListing bool noDotfileBlock bool @@ -255,6 +318,25 @@ func applyFlagOverrides(cfg *config.Config, f flagOverrides) error { if f.noCache { cfg.Cache.Enabled = false } + if f.preload { + cfg.Cache.Preload = true + } + if f.gcPercent != 0 { + cfg.Cache.GCPercent = f.gcPercent + } + if f.benchmarkMode { + cfg.Cache.Enabled = false + cfg.Compression.Enabled = false + cfg.Files.NotFound = "" + cfg.Security.BlockDotfiles = false + cfg.Security.DirectoryListing = false + cfg.Security.CORSOrigins = nil + cfg.Security.CSP = "" + cfg.Security.ReferrerPolicy = "" + cfg.Security.PermissionsPolicy = "" + cfg.Security.HSTSMaxAge = 0 + cfg.Security.HSTSIncludeSubdomains = false + } if f.cacheSize != "" { n, err := parseBytes(f.cacheSize) if err != nil { @@ -329,14 +411,33 @@ func parseBytes(s string) (int64, error) { return n * multiplier, nil } +// formatByteSize returns a human-readable string like "7.7 KB" or "256.0 MB". +func formatByteSize(b int64) string { + const ( + kb = 1024 + mb = 1024 * 1024 + gb = 1024 * 1024 * 1024 + ) + switch { + case b >= gb: + return fmt.Sprintf("%.1f GB", float64(b)/float64(gb)) + case b >= mb: + return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) + case b >= kb: + return fmt.Sprintf("%.1f KB", float64(b)/float64(kb)) + default: + return fmt.Sprintf("%d B", b) + } +} + // logConfig writes the resolved configuration to the standard logger. func logConfig(cfg *config.Config) { log.Printf("[config] server.addr=%s tls_addr=%s redirect_host=%q tls_cert=%q tls_key=%q", cfg.Server.Addr, cfg.Server.TLSAddr, cfg.Server.RedirectHost, cfg.Server.TLSCert, cfg.Server.TLSKey) log.Printf("[config] files.root=%q files.index=%q files.not_found=%q", cfg.Files.Root, cfg.Files.Index, cfg.Files.NotFound) - log.Printf("[config] cache.enabled=%v cache.max_bytes=%d cache.max_file_size=%d", - cfg.Cache.Enabled, cfg.Cache.MaxBytes, cfg.Cache.MaxFileSize) + log.Printf("[config] cache.enabled=%v cache.preload=%v cache.max_bytes=%d cache.max_file_size=%d cache.gc_percent=%d", + cfg.Cache.Enabled, cfg.Cache.Preload, cfg.Cache.MaxBytes, cfg.Cache.MaxFileSize, cfg.Cache.GCPercent) log.Printf("[config] compression.enabled=%v compression.min_size=%d compression.level=%d", cfg.Compression.Enabled, cfg.Compression.MinSize, cfg.Compression.Level) log.Printf("[config] security.block_dotfiles=%v security.directory_listing=%v security.cors_origins=%v", @@ -439,7 +540,10 @@ Serve flags: --404 string custom 404 page, relative to root --no-cache disable in-memory file cache --cache-size string max cache size, e.g. 256MB, 1GB (default 256MB) + --preload preload all files into cache at startup + --gc-percent int set Go GC target %% (0=default, 400 for high throughput) --no-compress disable response compression + --benchmark-mode serve files via a minimal benchmark-oriented handler --cors string CORS origins, comma-separated or * for all --dir-listing enable directory listing --no-dotfile-block disable dotfile blocking diff --git a/internal/cache/cache.go b/internal/cache/cache.go index a5a4d91..58e326d 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -4,6 +4,7 @@ package cache import ( + "strconv" "sync" "sync/atomic" "time" @@ -40,6 +41,23 @@ type CachedFile struct { Size int64 // ExpiresAt is the cache entry expiry time. Zero means no expiry. ExpiresAt time.Time + + // Pre-formatted header values avoid per-request string formatting. + // These are populated by InitHeaders() or by the preload path. + CTHeader []string // e.g. {"text/html; charset=utf-8"} + CLHeader []string // e.g. {"2943"} — raw data Content-Length +} + +// InitHeaders pre-formats the Content-Type and Content-Length header slices +// so that the serving hot path can assign them directly to the header map +// without allocating. This is idempotent. +func (f *CachedFile) InitHeaders() { + if f.CTHeader == nil { + f.CTHeader = []string{f.ContentType} + } + if f.CLHeader == nil { + f.CLHeader = []string{strconv.FormatInt(f.Size, 10)} + } } // totalSize returns the approximate byte footprint of the entry. diff --git a/internal/cache/preload.go b/internal/cache/preload.go new file mode 100644 index 0000000..a7f8b77 --- /dev/null +++ b/internal/cache/preload.go @@ -0,0 +1,215 @@ +package cache + +import ( + "crypto/sha256" + "fmt" + "io/fs" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "strings" +) + +// PreloadStats holds the results of a cache preload operation. +type PreloadStats struct { + // Files is the number of files successfully loaded into cache. + Files int + // Bytes is the total raw (uncompressed) bytes loaded. + Bytes int64 + // Skipped is the number of files skipped (too large, unreadable, etc.). + Skipped int + // Paths is the list of URL keys that were loaded into cache, for pre-warming + // downstream caches (e.g. security.PathCache). + Paths []string +} + +// PreloadConfig controls preload behaviour. It mirrors the subset of the +// full config that the preload step needs, avoiding a circular import +// with the config package. +type PreloadConfig struct { + // MaxFileSize is the maximum individual file size to preload. + MaxFileSize int64 + // IndexFile is the index filename for directory requests (e.g. "index.html"). + IndexFile string + // BlockDotfiles skips files whose path components start with ".". + BlockDotfiles bool + // CompressEnabled enables pre-compression of loaded files. + CompressEnabled bool + // CompressMinSize is the minimum file size for compression. + CompressMinSize int + // CompressLevel is the gzip compression level. + CompressLevel int + // CompressFn is an optional function that gzip-compresses src. + // When nil, no gzip variants are produced. + CompressFn func(src []byte, level int) ([]byte, error) +} + +// Preload walks root and loads every eligible regular file into the cache. +// Files larger than cfg.MaxFileSize or whose path contains dotfile segments +// (when cfg.BlockDotfiles is true) are skipped. The function returns stats +// describing what was loaded. +func (c *Cache) Preload(root string, cfg PreloadConfig) PreloadStats { + absRoot, err := filepath.Abs(root) + if err != nil { + return PreloadStats{} + } + if real, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = real + } + + indexFile := cfg.IndexFile + if indexFile == "" { + indexFile = "index.html" + } + + var stats PreloadStats + + _ = filepath.WalkDir(absRoot, func(fpath string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + + // Skip dotfile components. + if cfg.BlockDotfiles { + rel, relErr := filepath.Rel(absRoot, fpath) + if relErr != nil { + stats.Skipped++ + return nil + } + if hasDotfileSegment(rel) { + stats.Skipped++ + return nil + } + } + + info, err := d.Info() + if err != nil { + stats.Skipped++ + return nil + } + + // Skip files exceeding max size. + if info.Size() > cfg.MaxFileSize { + stats.Skipped++ + return nil + } + + data, err := os.ReadFile(fpath) + if err != nil { + stats.Skipped++ + return nil + } + + rel, err := filepath.Rel(absRoot, fpath) + if err != nil { + stats.Skipped++ + return nil + } + + // Build the URL-style cache key. + urlKey := "/" + filepath.ToSlash(rel) + + ct := detectMIMEType(fpath, data) + etag := computeFileETag(data) + + cached := &CachedFile{ + Data: data, + ETag: etag, + ETagFull: `W/"` + etag + `"`, + LastModified: info.ModTime(), + ContentType: ct, + Size: info.Size(), + } + + // Pre-compress if eligible. + if cfg.CompressEnabled && cfg.CompressFn != nil && + isCompressibleType(ct) && len(data) >= cfg.CompressMinSize { + if gz, err := cfg.CompressFn(data, cfg.CompressLevel); err == nil { + cached.GzipData = gz + } + } + + cached.InitHeaders() + + c.Put(urlKey, cached) + stats.Files++ + stats.Bytes += info.Size() + stats.Paths = append(stats.Paths, urlKey) + + // Also register the directory path if this is the index file. + if path.Base(urlKey) == indexFile { + dir := path.Dir(urlKey) + if dir != "/" { + dir += "/" + } + c.Put(dir, cached) + stats.Paths = append(stats.Paths, dir) + } + + return nil + }) + + return stats +} + +// hasDotfileSegment reports whether any path component starts with ".". +func hasDotfileSegment(rel string) bool { + for _, seg := range strings.Split(filepath.ToSlash(rel), "/") { + if seg != "" && strings.HasPrefix(seg, ".") { + return true + } + } + return false +} + +// detectMIMEType returns the MIME type for a file, falling back to +// http.DetectContentType for unknown extensions. +func detectMIMEType(filePath string, data []byte) string { + ext := strings.ToLower(filepath.Ext(filePath)) + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + if data != nil { + snippet := data + if len(snippet) > 512 { + snippet = snippet[:512] + } + return http.DetectContentType(snippet) + } + return "application/octet-stream" +} + +// computeFileETag returns the first 16 hex characters of sha256(data). +func computeFileETag(data []byte) string { + sum := sha256.Sum256(data) + return fmt.Sprintf("%x", sum)[:16] +} + +// isCompressibleType reports whether the MIME type is eligible for compression. +// This is a subset duplicated here to avoid importing the compress package. +var compressiblePrefixes = []string{ + "text/", + "application/javascript", + "application/json", + "application/xml", + "application/wasm", + "image/svg+xml", + "font/", + "application/font-woff", +} + +func isCompressibleType(ct string) bool { + // Strip parameters. + if idx := strings.Index(ct, ";"); idx >= 0 { + ct = strings.TrimSpace(ct[:idx]) + } + ct = strings.ToLower(ct) + for _, prefix := range compressiblePrefixes { + if strings.HasPrefix(ct, prefix) { + return true + } + } + return false +} diff --git a/internal/config/config.go b/internal/config/config.go index a486eb6..c7021a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,12 +62,22 @@ type FilesConfig struct { type CacheConfig struct { // Enabled turns the in-memory cache on or off. Default: true. Enabled bool `toml:"enabled"` + // Preload walks the files root at startup and loads every eligible file + // into the in-memory cache so that the first request for each file is + // served from RAM instead of hitting the filesystem. Default: false. + Preload bool `toml:"preload"` // MaxBytes is the maximum total byte size for the cache. Default: 256 MB. MaxBytes int64 `toml:"max_bytes"` // MaxFileSize is the maximum individual file size to cache. Default: 10 MB. MaxFileSize int64 `toml:"max_file_size"` // TTL is an optional time-to-live for cache entries (0 means no expiry). TTL time.Duration `toml:"ttl"` + // GCPercent sets the Go runtime garbage collector target percentage via + // debug.SetGCPercent(). A higher value reduces GC frequency at the cost of + // more memory. The default value of 0 means "do not change" (use Go's + // default of 100). Recommended: 400 for high-throughput deployments + // serving preloaded files. + GCPercent int `toml:"gc_percent"` } // CompressionConfig controls response compression settings. @@ -223,6 +233,9 @@ func applyEnvOverrides(cfg *Config) { if v := os.Getenv("STATIC_CACHE_ENABLED"); v != "" { cfg.Cache.Enabled = strings.EqualFold(v, "true") || v == "1" } + if v := os.Getenv("STATIC_CACHE_PRELOAD"); v != "" { + cfg.Cache.Preload = strings.EqualFold(v, "true") || v == "1" + } if v := os.Getenv("STATIC_CACHE_MAX_BYTES"); v != "" { if n, err := strconv.ParseInt(v, 10, 64); err == nil { cfg.Cache.MaxBytes = n @@ -238,6 +251,11 @@ func applyEnvOverrides(cfg *Config) { cfg.Cache.TTL = d } } + if v := os.Getenv("STATIC_CACHE_GC_PERCENT"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Cache.GCPercent = n + } + } if v := os.Getenv("STATIC_COMPRESSION_ENABLED"); v != "" { cfg.Compression.Enabled = strings.EqualFold(v, "true") || v == "1" diff --git a/internal/handler/file.go b/internal/handler/file.go index 773907f..50ca95b 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -13,6 +13,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "github.com/BackendStack21/static-web/internal/cache" @@ -129,18 +130,19 @@ func (h *FileHandler) resolveIndexPath(absPath, urlPath string) (resolvedPath, c } // serveFromCache writes a cached file to the response, respecting Accept-Encoding. +// +// For ordinary GET requests (no Range header) it uses a direct w.Write() path +// that avoids the overhead of http.ServeContent (range parsing, content-type +// sniffing, conditional-header re-checking — all unnecessary when the file is +// already fully in memory and we've handled 304s ourselves). +// +// Range requests still go through http.ServeContent for correct multi-range and +// 206 Partial Content support. func (h *FileHandler) serveFromCache(w http.ResponseWriter, r *http.Request, urlPath string, f *cache.CachedFile) { w.Header().Set("X-Cache", "HIT") - // Set ETag and Cache-Control headers (not already set by headers middleware if this is a 200). + // Set ETag and Cache-Control headers. headers.SetFileHeaders(w, urlPath, f, &h.cfg.Headers) - w.Header().Set("Content-Type", f.ContentType) - - if r.Method == http.MethodHead { - w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size)) - w.WriteHeader(http.StatusOK) - return - } // Negotiate content encoding using pre-compressed variants. data, encoding := h.negotiateEncoding(r, f) @@ -150,10 +152,58 @@ func (h *FileHandler) serveFromCache(w http.ResponseWriter, r *http.Request, url w.Header().Add("Vary", "Accept-Encoding") } - // Use http.ServeContent for Range request support. - // Wrap bytes.Reader so http.ServeContent can seek and detect size. + // --- Fast path: non-Range GET/HEAD ---------------------------------- + // Assign pre-formatted header slices directly to the underlying map, + // bypassing Header.Set() canonicalization overhead. Falls back to + // Set() if InitHeaders() was never called (defensive). + if r.Header.Get("Range") == "" { + hdr := w.Header() + if f.CTHeader != nil { + hdr["Content-Type"] = f.CTHeader + } else { + hdr.Set("Content-Type", f.ContentType) + } + + if r.Method == http.MethodHead { + if f.CLHeader != nil { + hdr["Content-Length"] = f.CLHeader + } else { + hdr.Set("Content-Length", fmt.Sprintf("%d", f.Size)) + } + w.WriteHeader(http.StatusOK) + return + } + + // For compressed data the Content-Length must reflect the encoded + // size, not the original — compute it only when encoding differs. + if encoding != "" { + hdr.Set("Content-Length", strconv.Itoa(len(data))) + } else if f.CLHeader != nil { + hdr["Content-Length"] = f.CLHeader + } else { + hdr.Set("Content-Length", fmt.Sprintf("%d", f.Size)) + } + + w.WriteHeader(http.StatusOK) + w.Write(data) //nolint:errcheck // best-effort write to network + return + } + + // --- Slow path: Range requests -------------------------------------- + // Content-Type must be set before ServeContent to prevent sniffing. + w.Header().Set("Content-Type", f.ContentType) + + // For range requests with compressed data we must serve the raw bytes + // because byte-range offsets apply to the uncompressed content. + if encoding != "" { + // Remove the Content-Encoding we set above — Range semantics + // require uncompressed data. + w.Header().Del("Content-Encoding") + data = f.Data + } + reader := bytes.NewReader(data) - http.ServeContent(w, r, urlPath, f.LastModified, reader) + http.ServeContent(w, r, "", f.LastModified, reader) } // negotiateEncoding selects the best pre-compressed variant for the client. @@ -241,6 +291,9 @@ func (h *FileHandler) serveFromDisk(w http.ResponseWriter, r *http.Request, absP } } + // Pre-format headers for the fast serving path. + cached.InitHeaders() + // Store in cache. if h.cfg.Cache.Enabled && h.cache != nil { h.cache.Put(urlPath, cached) diff --git a/internal/handler/middleware.go b/internal/handler/middleware.go index d646693..cde4433 100644 --- a/internal/handler/middleware.go +++ b/internal/handler/middleware.go @@ -18,18 +18,29 @@ import ( // http.Handler. The chain is (outer to inner): // // recovery → logging → security → compress → file handler -func BuildHandler(cfg *config.Config, c *cache.Cache) http.Handler { - return buildHandlerWithLogger(cfg, c, false) +// +// An optional *security.PathCache may be provided to cache path validation +// results and skip per-request filesystem syscalls for repeated URL paths. +func BuildHandler(cfg *config.Config, c *cache.Cache, pc ...*security.PathCache) http.Handler { + var pathCache *security.PathCache + if len(pc) > 0 { + pathCache = pc[0] + } + return buildHandlerWithLogger(cfg, c, false, pathCache) } // BuildHandlerQuiet is like BuildHandler but suppresses per-request access logging. // Use this when the --quiet flag is set. -func BuildHandlerQuiet(cfg *config.Config, c *cache.Cache) http.Handler { - return buildHandlerWithLogger(cfg, c, true) +func BuildHandlerQuiet(cfg *config.Config, c *cache.Cache, pc ...*security.PathCache) http.Handler { + var pathCache *security.PathCache + if len(pc) > 0 { + pathCache = pc[0] + } + return buildHandlerWithLogger(cfg, c, true, pathCache) } // buildHandlerWithLogger is the shared implementation. quiet=true discards access logs. -func buildHandlerWithLogger(cfg *config.Config, c *cache.Cache, quiet bool) http.Handler { +func buildHandlerWithLogger(cfg *config.Config, c *cache.Cache, quiet bool, pathCache *security.PathCache) http.Handler { // Core file handler. fileHandler := NewFileHandler(cfg, c) @@ -37,7 +48,7 @@ func buildHandlerWithLogger(cfg *config.Config, c *cache.Cache, quiet bool) http compressed := compress.Middleware(&cfg.Compression, fileHandler) // Security middleware: path validation + security headers. - withSecurity := security.Middleware(&cfg.Security, cfg.Files.Root, compressed) + withSecurity := security.Middleware(&cfg.Security, cfg.Files.Root, compressed, pathCache) // Request logging (suppressed when quiet=true). if quiet { diff --git a/internal/handler/minimal.go b/internal/handler/minimal.go new file mode 100644 index 0000000..3f1d6ee --- /dev/null +++ b/internal/handler/minimal.go @@ -0,0 +1,127 @@ +package handler + +import ( + "io/fs" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/BackendStack21/static-web/internal/config" +) + +// preloadedFile holds a file's content and pre-built header values, ready to +// serve without any per-request filesystem interaction or allocation. +type preloadedFile struct { + body []byte + ctHeader []string // pre-allocated {"text/html; charset=utf-8"} + clHeader []string // pre-allocated {"2943"} +} + +// BuildBenchmarkHandler returns a handler optimised for raw throughput in +// apples-to-apples benchmarks against other static file servers. All files +// under cfg.Files.Root are loaded into memory at startup so the hot path is +// a pure map lookup + w.Write() with zero syscalls and zero allocations. +// +// Security: path traversal is validated at startup (only real files under root +// are indexed). At request time the URL is cleaned with path.Clean and looked +// up in the preloaded map — there is no way to escape the root. +func BuildBenchmarkHandler(cfg *config.Config) http.Handler { + absRoot, err := filepath.Abs(cfg.Files.Root) + if err != nil { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "Internal Server Error: invalid root path", http.StatusInternalServerError) + }) + } + if real, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = real + } + + indexFile := cfg.Files.Index + if indexFile == "" { + indexFile = "index.html" + } + + // Preload every regular file under absRoot into a map keyed by its + // URL path (e.g. "/style.css", "/index.html"). + files := make(map[string]*preloadedFile, 32) + + _ = filepath.WalkDir(absRoot, func(fpath string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil // skip dirs and errors + } + + rel, err := filepath.Rel(absRoot, fpath) + if err != nil { + return nil + } + + body, err := os.ReadFile(fpath) + if err != nil { + return nil // skip unreadable files + } + + // Build the URL-style key: always forward-slash, rooted. + urlKey := "/" + filepath.ToSlash(rel) + + ct := mime.TypeByExtension(filepath.Ext(fpath)) + if ct == "" { + ct = "application/octet-stream" + } + + pf := &preloadedFile{ + body: body, + ctHeader: []string{ct}, + clHeader: []string{strconv.Itoa(len(body))}, + } + files[urlKey] = pf + + // If this file is the index, also register it for the directory path. + if path.Base(urlKey) == indexFile { + dir := path.Dir(urlKey) + if dir != "/" { + dir += "/" + } + files[dir] = pf + } + + return nil + }) + + // Pre-compute the 404 response body. + notFoundBody := []byte("404 page not found\n") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Method gate — GET is overwhelmingly common so check it first. + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // Clean and look up. path.Clean is cheap for already-clean paths + // (which is the common case for benchmarks hitting "/"). + clean := path.Clean(r.URL.Path) + pf := files[clean] + if pf == nil && clean != "/" && !strings.HasSuffix(clean, "/") { + // Try with trailing slash (directory with index). + pf = files[clean+"/"] + } + if pf == nil { + w.WriteHeader(http.StatusNotFound) + w.Write(notFoundBody) //nolint:errcheck + return + } + + // Direct map assignment avoids Header().Set() overhead (no canonicalization). + h := w.Header() + h["Content-Type"] = pf.ctHeader + h["Content-Length"] = pf.clHeader + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodGet { + w.Write(pf.body) //nolint:errcheck + } + }) +} diff --git a/internal/handler/minimal_test.go b/internal/handler/minimal_test.go new file mode 100644 index 0000000..0ba6ee0 --- /dev/null +++ b/internal/handler/minimal_test.go @@ -0,0 +1,190 @@ +package handler_test + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/BackendStack21/static-web/internal/config" + "github.com/BackendStack21/static-web/internal/handler" +) + +func TestBuildBenchmarkHandlerServesIndex(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("

ok

"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + cfg.Files.Root = root + cfg.Files.Index = "index.html" + + h := handler.BuildBenchmarkHandler(cfg) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if got := rr.Body.String(); got != "

ok

" { + t.Fatalf("body = %q, want %q", got, "

ok

") + } + // Benchmark handler should not set X-Cache. + if got := rr.Header().Get("X-Cache"); got != "" { + t.Fatalf("X-Cache = %q, want empty", got) + } +} + +func TestBuildBenchmarkHandlerServesNamedFile(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "style.css"), []byte("body{}"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + cfg.Files.Root = root + + h := handler.BuildBenchmarkHandler(cfg) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/style.css", nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if got := rr.Body.String(); got != "body{}" { + t.Fatalf("body = %q, want %q", got, "body{}") + } + if ct := rr.Header().Get("Content-Type"); ct != "text/css; charset=utf-8" { + t.Fatalf("Content-Type = %q, want text/css", ct) + } +} + +func TestBuildBenchmarkHandlerRejectsPost(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + cfg.Files.Root = root + + h := handler.BuildBenchmarkHandler(cfg) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", rr.Code) + } +} + +func TestBuildBenchmarkHandlerHidesMissingEscapeTargets(t *testing.T) { + root := t.TempDir() + cfg := &config.Config{} + cfg.Files.Root = root + + h := handler.BuildBenchmarkHandler(cfg) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/../../does-not-exist", nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } +} + +func TestBuildBenchmarkHandlerReturns404ForMissingFile(t *testing.T) { + root := t.TempDir() + cfg := &config.Config{} + cfg.Files.Root = root + + h := handler.BuildBenchmarkHandler(cfg) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/nope.txt", nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } +} + +func TestBuildBenchmarkHandlerHEADOmitsBody(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("

ok

"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + cfg.Files.Root = root + cfg.Files.Index = "index.html" + + h := handler.BuildBenchmarkHandler(cfg) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodHead, "/", nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if rr.Body.Len() != 0 { + t.Fatalf("HEAD body should be empty, got %d bytes", rr.Body.Len()) + } + if cl := rr.Header().Get("Content-Length"); cl != "11" { + t.Fatalf("Content-Length = %q, want 11", cl) + } +} + +func TestBuildBenchmarkHandlerSubdirIndex(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "docs") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "index.html"), []byte("docs"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + cfg.Files.Root = root + cfg.Files.Index = "index.html" + + h := handler.BuildBenchmarkHandler(cfg) + + // Request /docs should resolve to /docs/index.html. + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/docs", nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if got := rr.Body.String(); got != "docs" { + t.Fatalf("body = %q, want %q", got, "docs") + } +} + +func BenchmarkBenchmarkHandler(b *testing.B) { + root := b.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("

bench

"), 0644); err != nil { + b.Fatal(err) + } + + cfg := &config.Config{} + cfg.Files.Root = root + cfg.Files.Index = "index.html" + + h := handler.BuildBenchmarkHandler(cfg) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } +} diff --git a/internal/security/security.go b/internal/security/security.go index a1a866a..af635e9 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -10,6 +10,7 @@ import ( "path" "path/filepath" "strings" + "sync" "github.com/BackendStack21/static-web/internal/config" ) @@ -38,6 +39,70 @@ func SafePathFromContext(ctx context.Context) (string, bool) { return v, ok && v != "" } +// --------------------------------------------------------------------------- +// PathCache — caches urlPath → safePath to avoid per-request syscalls +// --------------------------------------------------------------------------- + +// PathCache caches the results of PathSafe so that repeated requests for the +// same URL path skip the filesystem syscalls (filepath.EvalSymlinks). +// It is safe for concurrent use. +type PathCache struct { + m sync.Map // urlPath (string) → safePath (string) +} + +// NewPathCache creates a new empty PathCache. +func NewPathCache() *PathCache { + return &PathCache{} +} + +// Lookup returns the cached safe path for urlPath, or ("", false) on miss. +func (pc *PathCache) Lookup(urlPath string) (string, bool) { + v, ok := pc.m.Load(urlPath) + if !ok { + return "", false + } + return v.(string), true +} + +// Store records a urlPath → safePath mapping in the cache. +func (pc *PathCache) Store(urlPath, safePath string) { + pc.m.Store(urlPath, safePath) +} + +// Flush removes all entries from the cache. Call this on SIGHUP alongside +// the file cache flush to ensure stale path mappings don't persist. +func (pc *PathCache) Flush() { + pc.m.Range(func(key, _ any) bool { + pc.m.Delete(key) + return true + }) +} + +// PreWarm populates the cache for a set of known URL paths by running each +// through PathSafe. Paths that fail validation are silently skipped. +func (pc *PathCache) PreWarm(paths []string, absRoot string, blockDotfiles bool) { + for _, urlPath := range paths { + safePath, err := PathSafe(urlPath, absRoot, blockDotfiles) + if err == nil { + pc.m.Store(urlPath, safePath) + } + } +} + +// Len returns the number of entries in the cache. +func (pc *PathCache) Len() int { + n := 0 + pc.m.Range(func(_, _ any) bool { + n++ + return true + }) + return n +} + +// --------------------------------------------------------------------------- +// PathSafe +// --------------------------------------------------------------------------- + // PathSafe validates and resolves urlPath relative to absRoot. // absRoot must already be an absolute, cleaned path (use filepath.Abs once at // startup). The function performs the following checks in order: @@ -121,6 +186,10 @@ func PathSafe(urlPath, absRoot string, blockDotfiles bool) (string, error) { return resolved, nil } +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- + // Middleware returns an http.Handler that validates the request path and sets // security response headers before delegating to next. // It returns 400 for null bytes, 403 for path traversal and dotfile attempts, @@ -131,7 +200,11 @@ func PathSafe(urlPath, absRoot string, blockDotfiles bool) (string, error) { // per-request syscall overhead. The resolved safe path is stored in the // request context so downstream handlers can retrieve it with // SafePathFromContext instead of calling PathSafe a second time. -func Middleware(cfg *config.SecurityConfig, root string, next http.Handler) http.Handler { +// +// An optional *PathCache may be provided to cache PathSafe results so that +// repeated requests for the same URL path skip the filesystem syscalls +// entirely. Pass nil (or omit) to disable path caching. +func Middleware(cfg *config.SecurityConfig, root string, next http.Handler, pc ...*PathCache) http.Handler { // Resolve absRoot once at startup — not on every request. absRoot, err := filepath.Abs(root) if err != nil { @@ -147,6 +220,12 @@ func Middleware(cfg *config.SecurityConfig, root string, next http.Handler) http absRoot = real } + // Extract optional path cache. + var pathCache *PathCache + if len(pc) > 0 { + pathCache = pc[0] + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Set security headers on every response, including errors (SEC-006). setSecurityHeaders(w, cfg) @@ -161,17 +240,33 @@ func Middleware(cfg *config.SecurityConfig, root string, next http.Handler) http return } - safePath, err := PathSafe(r.URL.Path, absRoot, cfg.BlockDotfiles) - if err != nil { - switch { - case errors.Is(err, ErrNullByte): - http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest) - case errors.Is(err, ErrPathTraversal), errors.Is(err, ErrDotfile): - http.Error(w, "Forbidden: "+err.Error(), http.StatusForbidden) - default: - http.Error(w, "Forbidden", http.StatusForbidden) + // Fast path: check the path cache before hitting the filesystem. + var safePath string + if pathCache != nil { + if cached, ok := pathCache.Lookup(r.URL.Path); ok { + safePath = cached + } + } + + if safePath == "" { + var pathErr error + safePath, pathErr = PathSafe(r.URL.Path, absRoot, cfg.BlockDotfiles) + if pathErr != nil { + switch { + case errors.Is(pathErr, ErrNullByte): + http.Error(w, "Bad Request: "+pathErr.Error(), http.StatusBadRequest) + case errors.Is(pathErr, ErrPathTraversal), errors.Is(pathErr, ErrDotfile): + http.Error(w, "Forbidden: "+pathErr.Error(), http.StatusForbidden) + default: + http.Error(w, "Forbidden", http.StatusForbidden) + } + return + } + + // Cache the successful result. + if pathCache != nil { + pathCache.Store(r.URL.Path, safePath) } - return } // Handle CORS preflight. diff --git a/internal/server/signals.go b/internal/server/signals.go index d64ee1a..ece5695 100644 --- a/internal/server/signals.go +++ b/internal/server/signals.go @@ -9,6 +9,7 @@ import ( "github.com/BackendStack21/static-web/internal/cache" "github.com/BackendStack21/static-web/internal/config" + "github.com/BackendStack21/static-web/internal/security" ) // RunSignalHandler blocks until SIGTERM or SIGINT is received, then gracefully @@ -20,7 +21,13 @@ import ( // - c: the cache to flush on SIGHUP // - cfgPath: path to the TOML config file (used for SIGHUP reload) // - cfgPtr: pointer to the current config pointer, updated on reload -func RunSignalHandler(ctx context.Context, srv *Server, c *cache.Cache, cfgPath string, cfgPtr **config.Config) { +// - pc: optional path cache to flush on SIGHUP (may be nil) +func RunSignalHandler(ctx context.Context, srv *Server, c *cache.Cache, cfgPath string, cfgPtr **config.Config, pc ...*security.PathCache) { + var pathCache *security.PathCache + if len(pc) > 0 { + pathCache = pc[0] + } + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) defer signal.Stop(sigCh) @@ -33,7 +40,7 @@ func RunSignalHandler(ctx context.Context, srv *Server, c *cache.Cache, cfgPath case sig := <-sigCh: switch sig { case syscall.SIGHUP: - handleHUP(c, cfgPath, cfgPtr) + handleHUP(c, pathCache, cfgPath, cfgPtr) case syscall.SIGTERM, syscall.SIGINT: log.Printf("signal: received %s, initiating graceful shutdown", sig) @@ -44,8 +51,8 @@ func RunSignalHandler(ctx context.Context, srv *Server, c *cache.Cache, cfgPath } } -// handleHUP reloads the config file and flushes the cache. -func handleHUP(c *cache.Cache, cfgPath string, cfgPtr **config.Config) { +// handleHUP reloads the config file and flushes both file and path caches. +func handleHUP(c *cache.Cache, pc *security.PathCache, cfgPath string, cfgPtr **config.Config) { log.Printf("signal: SIGHUP received — reloading config from %q", cfgPath) newCfg, err := config.Load(cfgPath) @@ -56,7 +63,10 @@ func handleHUP(c *cache.Cache, cfgPath string, cfgPtr **config.Config) { *cfgPtr = newCfg c.Flush() - log.Printf("signal: config reloaded and cache flushed successfully") + if pc != nil { + pc.Flush() + } + log.Printf("signal: config reloaded and caches flushed successfully") } // handleShutdown performs a graceful shutdown with a timeout derived from config. From 538cff5119b09f2d396ec5178da2dee82096fd28 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 8 Mar 2026 10:00:51 +0100 Subject: [PATCH 07/17] docs: update all documentation and landing page for preload performance - README: new end-to-end benchmarks (137k req/sec), preload/gc_percent config and env vars, updated architecture diagram with path cache - CLI.md: add --preload and --gc-percent to flag reference - USER_GUIDE.md: new 'Preloading for Maximum Performance' section, GC tuning guide, Docker preload example, updated SIGHUP docs - Landing page: hero stats show 137k req/sec, benchmark table replaces old Docker numbers with localhost results (beats Bun), updated feature cards, config tabs, structured data, and meta tags --- CLI.md | 9 +++- README.md | 62 +++++++++++++++--------- USER_GUIDE.md | 79 ++++++++++++++++++++++++++++--- docs/index.html | 122 ++++++++++++++++++++++-------------------------- 4 files changed, 178 insertions(+), 94 deletions(-) diff --git a/CLI.md b/CLI.md index 81a4556..e1809b5 100644 --- a/CLI.md +++ b/CLI.md @@ -186,6 +186,7 @@ Grouped by concern for readability. All flags are optional; unset flags do not o |------|------|---------|--------------| | `--host` | string | `` (all interfaces) | `server.addr` (host part) | | `--port`, `-p` | int | `8080` | `server.addr` (port part) | +| `--redirect-host` | string | — | `server.redirect_host` | | `--tls-cert` | string | — | `server.tls_cert` | | `--tls-key` | string | — | `server.tls_key` | | `--tls-port` | int | `8443` | `server.tls_addr` (port part) | @@ -205,6 +206,8 @@ Grouped by concern for readability. All flags are optional; unset flags do not o |------|------|---------|--------------| | `--no-cache` | bool | `false` | `cache.enabled = false` | | `--cache-size` | string | `256MB` | `cache.max_bytes` (parses `256MB`, `64MB`, `1GB`) | +| `--preload` | bool | `false` | `cache.preload` — load all files into cache at startup | +| `--gc-percent` | int | `0` | `cache.gc_percent` — Go GC target % (0 = default; try 400 for throughput) | #### Compression @@ -244,6 +247,7 @@ static-web --dir-listing --no-dotfile-block ~/Downloads # Serve with TLS (HTTPS on :443, HTTP redirect on :80) static-web --port 80 --tls-port 443 \ + --redirect-host static.example.com \ --tls-cert /etc/ssl/cert.pem \ --tls-key /etc/ssl/key.pem \ ./public @@ -265,6 +269,9 @@ static-web # Disable caching (useful during local development to see file changes immediately) static-web --no-cache ./dist +# Maximum throughput: preload all files + tune GC +static-web --preload --gc-percent 400 ./dist + # Print version info static-web version ``` @@ -385,7 +392,7 @@ The CLI was implemented using Go stdlib `flag.FlagSet` — no external framework - **`--host` + `--port` merging**: `net.SplitHostPort` / `net.JoinHostPort` used to decompose and reconstruct `server.addr`. - **`parseBytes()`**: a small helper that parses `256MB`, `1GB`, etc. with `B`/`KB`/`MB`/`GB` suffixes (case-insensitive). - **`//go:embed config.toml.example`**: the example config is embedded in `cmd/static-web/` at compile time. The binary is fully self-contained. -- **`--quiet`**: passes `io.Discard` to a `loggingMiddlewareWithWriter` variant, suppressing access log output with zero overhead. +- **`--quiet`**: skips access-log middleware entirely, removing per-request logging overhead. - **`--verbose`**: calls `logConfig(cfg)` after all overrides are applied, so you see the final resolved values. - **Version injection**: `internal/version.Version`, `Commit`, `Date` are set via `-ldflags` at build time. Default to `"dev"`, `"none"`, `"unknown"` for `go run`. diff --git a/README.md b/README.md index 8bbcacb..0608715 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ static-web --help | Feature | Detail | |---------|--------| -| **In-memory LRU cache** | Size-bounded, byte-accurate; zero-alloc hot path (~27 ns/op) | +| **In-memory LRU cache** | Size-bounded, byte-accurate; ~28 ns/op lookup with 0 allocations. Optional startup preload for instant cache hits. | | **gzip compression** | On-the-fly via pooled `gzip.Writer`; pre-compressed `.gz`/`.br` sidecar support | | **HTTP/2** | Automatic ALPN negotiation when TLS is configured | | **Conditional requests** | ETag, `304 Not Modified`, `If-Modified-Since`, `If-None-Match` | @@ -68,7 +68,7 @@ static-web --help | **Symlink escape prevention** | `EvalSymlinks` re-verified against root; symlinks pointing outside root are blocked | | **CORS** | Configurable per-origin or wildcard (`*` emits literal `*`, never reflected) | | **Graceful shutdown** | SIGTERM/SIGINT drains in-flight requests with configurable timeout | -| **Live cache flush** | SIGHUP flushes the in-memory cache without downtime | +| **Live cache flush** | SIGHUP flushes both the in-memory file cache and the path-safety cache without downtime | --- @@ -91,18 +91,13 @@ HTTP request │ • Method whitelist (GET/HEAD/OPTIONS only) │ │ • Security headers (set BEFORE path check) │ │ • PathSafe: null bytes, path.Clean, EvalSymlinks│ +│ • Path-safety cache (sync.Map, pre-warmed) │ │ • Dotfile blocking │ │ • CORS (preflight + per-origin or wildcard *) │ │ • Injects validated path into context │ └────────┬────────────────────────────────────────┘ │ ┌────────▼────────────────────────────────────────┐ -│ headers.Middleware │ -│ • 304 Not Modified (ETag, If-Modified-Since) │ -│ • Cache-Control, immutable pattern matching │ -└────────┬────────────────────────────────────────┘ - │ -┌────────▼────────────────────────────────────────┐ │ compress.Middleware │ │ • lazyGzipWriter: decides at first Write() │ │ • Skips 1xx/204/304, non-compressible types │ @@ -111,10 +106,12 @@ HTTP request │ ┌────────▼────────────────────────────────────────┐ │ handler.FileHandler │ -│ • Cache hit → serve from memory (zero os.Stat) │ +│ • Cache hit → direct w.Write() fast path │ +│ • Range/conditional → http.ServeContent │ │ • Cache miss → os.Stat → disk read → cache put │ │ • Large files (> max_file_size) bypass cache │ │ • Encoding negotiation: brotli > gzip > plain │ +│ • Preloaded files served instantly on startup │ │ • Custom 404 page (path-validated) │ └─────────────────────────────────────────────────┘ ``` @@ -125,32 +122,49 @@ HTTP request GET /app.js │ ├─ cache.Get("/app.js") hit? - │ YES → serveFromCache (no syscall) → done + │ YES → serveFromCache (direct w.Write, no syscall) → done │ └─ NO → resolveIndexPath → cache.Get(canonicalURL) hit? YES → serveFromCache → done NO → os.Stat → os.ReadFile → cache.Put → serveFromCache ``` +When `preload = true`, every eligible file is loaded into cache at startup. The path-safety cache (`sync.Map`) is also pre-warmed, so the very first request for any preloaded file skips both filesystem I/O and `EvalSymlinks`. + --- ## Performance -Benchmark numbers on Apple M2 Pro (`go test -bench=. -benchtime=5s`): +### End-to-end HTTP benchmarks + +Measured on Apple M2 Pro, localhost (no Docker), serving 3 small static files via `bombardier -c 50 -n 100000`: + +| Server | Avg Req/sec | p50 Latency | p99 Latency | +|--------|-------------|-------------|-------------| +| **static-web** (preload + GC 400) | **~137,000** | **321 µs** | **1.18 ms** | +| **static-web** (default config) | ~76,000 | 580 µs | 2.40 ms | +| Bun (native static serve) | ~129,000 | 361 µs | 0.84 ms | + +With `preload = true` and `gc_percent = 400`, static-web beats Bun's native static serving by ~6% in throughput. + +### Micro-benchmarks + +Measured on Apple M2 Pro (`go test -bench=. -benchtime=5s`): | Benchmark | ops/s | ns/op | allocs/op | |-----------|-------|-------|-----------| -| `BenchmarkCacheGet` | 87–131 M | 27 | 0 | -| `BenchmarkCachePut` | 42–63 M | 57 | 0 | -| `BenchmarkCacheGetParallel` | 15–25 M | 142–147 | 0 | -| `BenchmarkHandler_CacheHit` | — | ~5,840 | — | +| `BenchmarkCacheGet` | 35–42 M | 28–29 | 0 | +| `BenchmarkCacheGetParallel` | 6–8 M | 139–148 | 0 | -Key design decisions driving these numbers: +### Key design decisions +- **Preload at startup**: `preload = true` reads all eligible files into RAM before the first request — eliminating cold-miss latency. +- **Direct `w.Write()` fast path**: cache hits bypass `http.ServeContent` entirely; pre-formatted `Content-Type` and `Content-Length` headers are assigned directly. +- **Path-safety cache**: `sync.Map`-based cache eliminates per-request `filepath.EvalSymlinks` syscalls. Pre-warmed from preload. +- **GC tuning**: `gc_percent = 400` reduces garbage collection frequency — the hot path is allocation-free, but `net/http` internals allocate per-request. - **Cache-before-stat**: `os.Stat` is never called on a cache hit — the hot path is pure memory. - **Zero-alloc `AcceptsEncoding`**: walks the `Accept-Encoding` header byte-by-byte without `strings.Split`. - **Pooled `sync.Pool`**: both `gzip.Writer` and `statusResponseWriter` are pooled. -- **`filepath.Abs` at startup**: computed once during construction, never per-request. - **Pre-computed `ETagFull`**: the `W/"..."` string is built when the file is cached. --- @@ -211,6 +225,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start |-----|------|---------|-------------| | `addr` | string | `:8080` | HTTP listen address | | `tls_addr` | string | `:8443` | HTTPS listen address | +| `redirect_host` | string | — | Canonical host used for HTTP→HTTPS redirects | | `tls_cert` | string | — | Path to TLS certificate (PEM) | | `tls_key` | string | — | Path to TLS private key (PEM) | | `read_header_timeout` | duration | `5s` | Slowloris protection | @@ -232,9 +247,11 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start | Key | Type | Default | Description | |-----|------|---------|-------------| | `enabled` | bool | `true` | Toggle in-memory LRU cache | +| `preload` | bool | `false` | Load all eligible files into cache at startup | | `max_bytes` | int | `268435456` | Cache size cap (bytes) | | `max_file_size` | int | `10485760` | Max file size to cache (bytes) | | `ttl` | duration | `0` | Entry TTL (0 = no expiry; flush with SIGHUP) | +| `gc_percent` | int | `0` | Go GC target percentage (0 = use Go default of 100) | ### `[compression]` @@ -276,6 +293,7 @@ All environment variables override the corresponding TOML setting. Useful for co |----------|-------------| | `STATIC_SERVER_ADDR` | `server.addr` | | `STATIC_SERVER_TLS_ADDR` | `server.tls_addr` | +| `STATIC_SERVER_REDIRECT_HOST` | `server.redirect_host` | | `STATIC_SERVER_TLS_CERT` | `server.tls_cert` | | `STATIC_SERVER_TLS_KEY` | `server.tls_key` | | `STATIC_SERVER_READ_HEADER_TIMEOUT` | `server.read_header_timeout` | @@ -287,9 +305,11 @@ All environment variables override the corresponding TOML setting. Useful for co | `STATIC_FILES_INDEX` | `files.index` | | `STATIC_FILES_NOT_FOUND` | `files.not_found` | | `STATIC_CACHE_ENABLED` | `cache.enabled` | +| `STATIC_CACHE_PRELOAD` | `cache.preload` | | `STATIC_CACHE_MAX_BYTES` | `cache.max_bytes` | | `STATIC_CACHE_MAX_FILE_SIZE` | `cache.max_file_size` | | `STATIC_CACHE_TTL` | `cache.ttl` | +| `STATIC_CACHE_GC_PERCENT` | `cache.gc_percent` | | `STATIC_COMPRESSION_ENABLED` | `compression.enabled` | | `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` | | `STATIC_COMPRESSION_LEVEL` | `compression.level` | @@ -307,12 +327,13 @@ Set `tls_cert` and `tls_key` to enable HTTPS: [server] addr = ":80" tls_addr = ":443" +redirect_host = "static.example.com" tls_cert = "/etc/ssl/certs/server.pem" tls_key = "/etc/ssl/private/server.key" ``` When TLS is configured: -- HTTP requests on `addr` are automatically **redirected** to `tls_addr` with `301 Moved Permanently`. +- HTTP requests on `addr` are automatically **redirected** to HTTPS. Set `redirect_host` when `tls_addr` listens on all interfaces (for example `:443`) so redirects use a canonical host instead of the incoming `Host` header. - **HTTP/2** is enabled automatically via ALPN negotiation. - **HSTS** (`Strict-Transport-Security`) is added to all HTTPS responses (configurable max-age). - Minimum TLS version is **1.2**; preferred cipher suites are ECDHE+AES-256-GCM and ChaCha20-Poly1305. @@ -348,9 +369,9 @@ make precompress # runs gzip and brotli on all .js/.css/.html/.json/.svg |--------|--------| | `SIGTERM` | Graceful shutdown (drains in-flight requests up to `shutdown_timeout`) | | `SIGINT` | Graceful shutdown | -| `SIGHUP` | Flush in-memory cache; re-reads config pointer in `main` | +| `SIGHUP` | Flush in-memory file cache and path-safety cache; re-reads config pointer in `main` | -> **Note**: SIGHUP reloads the config pointer in `main` but the live middleware chain holds references to the old config. A full restart is required for config changes to take effect. SIGHUP is useful for flushing the cache without downtime. +> **Note**: SIGHUP reloads the config pointer in `main` but the live middleware chain holds references to the old config. A full restart is required for config changes to take effect. SIGHUP is useful for flushing both the file cache and the path-safety cache without downtime. --- @@ -400,5 +421,4 @@ go test -race ./... # all tests, race-free | Limitation | Detail | |------------|--------| | **Brotli on-the-fly** | Not implemented. Only pre-compressed `.br` sidecar files are served. | -| **Cache TTL not enforced** | `cache.ttl` is parsed but the expiry logic is not yet implemented. Use SIGHUP to flush manually. | | **SIGHUP config reload** | Reloads the config struct pointer in `main` only. Live middleware chains hold old references — full restart required for config changes to propagate. | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 965a031..fe4f0a3 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -110,6 +110,7 @@ Run `static-web --help` or see [CLI.md](CLI.md) for the full flag reference. [server] addr = ":8080" # HTTP listen address tls_addr = ":8443" # HTTPS listen address (requires tls_cert + tls_key) +redirect_host = "" # canonical host for HTTP→HTTPS redirects (recommended in production) tls_cert = "" # path to PEM certificate file tls_key = "" # path to PEM private key file read_header_timeout = "5s" # Slowloris protection @@ -127,7 +128,9 @@ not_found = "404.html" # custom 404 page, relative to root (optional) enabled = true max_bytes = 268435456 # 256 MB total cache cap max_file_size = 10485760 # files > 10 MB bypass the cache -ttl = "0s" # 0 = no expiry; flush manually with SIGHUP +ttl = "0s" # 0 = no expiry; >0 evicts stale entries on access +preload = false # true = load all files into RAM at startup +# gc_percent = 0 # Go GC target %; 400 recommended with preload [compression] enabled = true @@ -159,6 +162,7 @@ Every config field can also be set via an environment variable, which takes prec | ----------------------------------- | ------------------------------------------------ | | `STATIC_SERVER_ADDR` | `server.addr` | | `STATIC_SERVER_TLS_ADDR` | `server.tls_addr` | +| `STATIC_SERVER_REDIRECT_HOST` | `server.redirect_host` | | `STATIC_SERVER_TLS_CERT` | `server.tls_cert` | | `STATIC_SERVER_TLS_KEY` | `server.tls_key` | | `STATIC_SERVER_READ_HEADER_TIMEOUT` | `server.read_header_timeout` | @@ -170,9 +174,11 @@ Every config field can also be set via an environment variable, which takes prec | `STATIC_FILES_INDEX` | `files.index` | | `STATIC_FILES_NOT_FOUND` | `files.not_found` | | `STATIC_CACHE_ENABLED` | `cache.enabled` | +| `STATIC_CACHE_PRELOAD` | `cache.preload` | | `STATIC_CACHE_MAX_BYTES` | `cache.max_bytes` | | `STATIC_CACHE_MAX_FILE_SIZE` | `cache.max_file_size` | | `STATIC_CACHE_TTL` | `cache.ttl` | +| `STATIC_CACHE_GC_PERCENT` | `cache.gc_percent` | | `STATIC_COMPRESSION_ENABLED` | `compression.enabled` | | `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` | | `STATIC_COMPRESSION_LEVEL` | `compression.level` | @@ -212,6 +218,7 @@ Then in `config.toml`: [server] addr = ":8080" tls_addr = ":8443" +redirect_host = "localhost" tls_cert = "server.crt" tls_key = "server.key" ``` @@ -519,6 +526,17 @@ docker run --rm -p 8080:8080 \ docker kill --signal=HUP ``` +**Maximum throughput with preload (Docker env vars):** + +```bash +docker run --rm -p 8080:8080 \ + -v "$(pwd)/public:/public:ro" \ + -e STATIC_FILES_ROOT=/public \ + -e STATIC_CACHE_PRELOAD=true \ + -e STATIC_CACHE_GC_PERCENT=400 \ + static-web:latest +``` + --- ## Health Checks and Readiness Probes @@ -565,7 +583,7 @@ healthcheck: ## Live Cache Flush (SIGHUP) -Send `SIGHUP` to flush the in-memory LRU cache without restarting the server. This is useful after deploying updated static files to disk — new requests will read fresh content from disk and repopulate the cache. +Send `SIGHUP` to flush both the in-memory LRU file cache and the path-safety cache without restarting the server. This is useful after deploying updated static files to disk — new requests will read fresh content from disk and repopulate the cache. ```bash # by PID @@ -578,7 +596,57 @@ systemctl kill --signal=HUP static-web.service docker kill --signal=HUP ``` -> **Important:** SIGHUP only flushes the cache. It does **not** reload the configuration. Config changes require a full restart. +> **Important:** SIGHUP flushes the file cache and the path-safety cache. It does **not** reload the configuration. Config changes require a full restart. + +--- + +## Preloading for Maximum Performance + +Enable `preload` to read every eligible file into the in-memory cache at startup. Combined with GC tuning, this yields the highest possible throughput — up to **~137,000 req/sec** on Apple M2 Pro (beating Bun's native static serve). + +### Configuration + +```toml +[cache] +enabled = true +preload = true # load all files under [files.root] into RAM at startup +gc_percent = 400 # reduce GC frequency for throughput (default: 0 = Go default 100) +``` + +Or via CLI flags: + +```bash +static-web --preload --gc-percent 400 ./dist +``` + +Or via environment variables: + +```bash +STATIC_CACHE_PRELOAD=true STATIC_CACHE_GC_PERCENT=400 ./bin/static-web +``` + +### What preloading does + +1. At startup, walks every file under `files.root`. +2. Files smaller than `max_file_size` are read into the LRU cache. +3. Pre-formatted `Content-Type` and `Content-Length` response headers are computed once per file. +4. The path-safety cache (`sync.Map`) is pre-warmed — the first request for any preloaded file skips `filepath.EvalSymlinks`. +5. Preload statistics (file count, total bytes, duration) are logged at startup. + +### When to use preload + +- **Ideal**: bounded set of static files (SPA builds, marketing sites, docs sites). +- **Not recommended**: very large file trees where total size exceeds `max_bytes`, or directories with frequent file changes. + +### GC tuning + +`gc_percent` sets the Go runtime `GOGC` target. A higher value means the GC runs less often, trading memory for throughput. The handler's hot path is allocation-free, but `net/http` internals allocate per-request. Recommended values: + +| `gc_percent` | Behaviour | +|---|---| +| `0` | Do not change (Go default: 100) | +| `200` | Moderate: ~5% throughput boost | +| `400` | Aggressive: ~8% throughput boost (recommended with preload) | --- @@ -674,7 +742,6 @@ Directory listing is **disabled by default** (`directory_listing = false`). Enab | Limitation | Impact | Workaround | | ------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Brotli on-the-fly not implemented** | Brotli encoding requires pre-compressed `.br` files. | Run `make precompress` as part of your build pipeline. | -| **Cache TTL not enforced** | `cache.ttl` is parsed but entries never expire on their own. | Use SIGHUP to flush the cache after deploying new files. | | **No hot config reload** | SIGHUP flushes the cache only; config changes require a restart. | Use a process manager (systemd, Docker restart policy) for zero-downtime restarts. | --- @@ -700,13 +767,13 @@ The server only accepts `GET`, `HEAD`, and `OPTIONS`. Any other method (POST, PU ### Files are stale after a deploy -The in-memory cache serves files from memory after the first request. After deploying new files to disk, flush the cache: +The in-memory cache serves files from memory after the first request (or immediately if `preload = true`). After deploying new files to disk, flush both the file cache and the path-safety cache: ```bash kill -HUP $(pgrep static-web) ``` -If `cache.ttl` is set, note that it is currently parsed but **not enforced** — entries do not expire automatically. SIGHUP is the only way to clear them. +If `cache.ttl` is `0`, entries remain cached until eviction pressure or SIGHUP flush. If `cache.ttl` is greater than `0`, stale entries are evicted automatically on access. ### Compression not working diff --git a/docs/index.html b/docs/index.html index e837315..925e5e3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ static-web — High-Performance Go Static File Server @@ -37,7 +37,7 @@ @@ -76,11 +76,11 @@ "operatingSystem": "Linux, macOS, Windows", "url": "https://static.21no.de", "downloadUrl": "https://github.com/BackendStack21/static-web/releases", - "softwareVersion": "1.1.0", + "softwareVersion": "1.2.0", "programmingLanguage": "Go", "license": "https://github.com/BackendStack21/static-web/blob/main/LICENSE", "codeRepository": "https://github.com/BackendStack21/static-web", - "description": "A production-grade, blazing-fast static web file server written in Go. Features in-memory LRU cache (~27 ns/op lookup), HTTP/2, TLS 1.2+, gzip and brotli compression, and comprehensive security headers.", + "description": "A production-grade, blazing-fast static web file server written in Go. 137k req/sec with startup preloading — faster than Bun. Features in-memory LRU cache, TTL-aware cache expiry, HTTP/2, TLS 1.2+, gzip and brotli compression, and comprehensive security headers.", "author": { "@type": "Person", "name": "Rolando Santamaria Maso", @@ -92,7 +92,11 @@ "priceCurrency": "USD" }, "featureList": [ - "In-memory LRU cache with ~27 ns/op lookup", + "137k req/sec with startup preloading — faster than Bun", + "In-memory LRU cache with ~28 ns/op lookup", + "Startup preloading with path-safety cache pre-warming", + "TTL-aware cache expiry with optional automatic stale-entry eviction", + "Direct w.Write() zero-alloc fast path for cache hits", "HTTP/2 with automatic HTTPS", "TLS 1.2+ with AEAD cipher suites", "gzip and brotli compression", @@ -219,19 +223,19 @@

static-web

Production-Grade Go Static File Server

- Blazing fast, lightweight static server with in-memory LRU cache, HTTP/2, TLS, gzip / brotli, - and security headers baked in — ready for production in minutes. + Blazing fast, lightweight static server with in-memory LRU cache, startup preloading, HTTP/2, TLS, gzip / brotli, + and security headers baked in — faster than Bun out of the box.

- ~27 ns - cache lookup + 137k + req/sec (preload)
- 0 allocs - on cache hit + 0 alloc + hot-path serving
@@ -296,10 +300,7 @@

Everything You Need

Zero-Alloc Hot Path

-

- LRU cache hit at ~27 ns/op with 0 allocations. os.Stat is never called on - a cache hit — pure memory. -

+

137k req/sec with preload. Cache hits use direct w.Write() with pre-formatted headers — no http.ServeContent, no syscalls, zero allocations on the hot path.

🗜️
@@ -328,10 +329,7 @@

Security Hardened

📦

Smart Caching

-

- Byte-accurate LRU cache with configurable max size, per-file size cap, ETag, and live flush via SIGHUP - without downtime. -

+

Byte-accurate LRU cache with startup preloading, configurable max size, per-file size cap, optional TTL expiry, ETag, and live flush via SIGHUP without downtime.

🔄
@@ -450,8 +448,10 @@

Getting Started

[cache] enabled = true +preload = true # load all files at startup max_bytes = 268435456 # 256 MiB max_file_size = 10485760 # 10 MiB +gc_percent = 400 # tune GC for throughput [compression] enabled = true @@ -550,19 +550,7 @@

Logging Middleware

🔐

Security Middleware

-

Method whitelist · security headers · path safety · dotfile block · CORS

-
-
- -
-
🏷️
-
-

Headers Middleware

-

ETag · 304 Not Modified · Cache-Control · immutable assets

+

Method whitelist · security headers · path safety (cached) · dotfile block · CORS