Files
ResolveSpec/pkg/server/staticweb
Hein 2017465cb8 feat(staticweb): add path prefix stripping to all filesystem providers
Add PrefixStrippingProvider interface and implement it in all providers
(EmbedFSProvider, LocalFSProvider, ZipFSProvider) to support serving
files from subdirectories at the root level.
2026-01-03 14:39:51 +02:00
..
2025-12-30 17:44:57 +02:00
2025-12-30 17:46:33 +02:00
2025-12-30 17:46:33 +02:00

StaticWeb - Interface-Driven Static File Server

StaticWeb is a flexible, interface-driven Go package for serving static files over HTTP. It supports multiple filesystem backends (local, zip, embedded) and provides pluggable policies for caching, MIME types, and fallback strategies.

Features

  • Router-Agnostic: Works with any HTTP router through standard http.Handler
  • Multiple Filesystem Providers: Local directories, zip files, embedded filesystems
  • Pluggable Policies: Customizable cache, MIME type, and fallback strategies
  • Thread-Safe: Safe for concurrent use
  • Resource Management: Proper lifecycle management with Close() methods
  • Extensible: Easy to add new providers and policies

Installation

go get github.com/bitechdev/ResolveSpec/pkg/server/staticweb

Quick Start

Basic Usage

Serve files from a local directory:

import "github.com/bitechdev/ResolveSpec/pkg/server/staticweb"

// Create service
service := staticweb.NewService(nil)

// Mount a local directory
provider, _ := staticweb.LocalProvider("./public")
service.Mount(staticweb.MountConfig{
    URLPrefix: "/static",
    Provider:  provider,
})

// Use with any router
router.PathPrefix("/").Handler(service.Handler())

Single Page Application (SPA)

Serve an SPA with HTML fallback routing:

service := staticweb.NewService(nil)

provider, _ := staticweb.LocalProvider("./dist")
service.Mount(staticweb.MountConfig{
    URLPrefix:        "/",
    Provider:         provider,
    FallbackStrategy: staticweb.HTMLFallback("index.html"),
})

// API routes take precedence (registered first)
router.HandleFunc("/api/users", usersHandler)
router.HandleFunc("/api/posts", postsHandler)

// Static files handle all other routes
router.PathPrefix("/").Handler(service.Handler())

Filesystem Providers

Local Directory

Serve files from a local filesystem directory:

provider, err := staticweb.LocalProvider("/var/www/static")

Zip File

Serve files from a zip archive:

provider, err := staticweb.ZipProvider("./static.zip")

Embedded Filesystem

Serve files from Go's embedded filesystem:

//go:embed assets
var assets embed.FS

// Direct embedded FS
provider, err := staticweb.EmbedProvider(&assets, "")

// Or from a zip file within embedded FS
provider, err := staticweb.EmbedProvider(&assets, "assets.zip")

Cache Policies

Simple Cache

Single TTL for all files:

cachePolicy := staticweb.SimpleCache(3600) // 1 hour

Extension-Based Cache

Different TTLs per file type:

rules := map[string]int{
    ".html": 3600,   // 1 hour
    ".js":   86400,  // 1 day
    ".css":  86400,  // 1 day
    ".png":  604800, // 1 week
}

cachePolicy := staticweb.ExtensionCache(rules, 3600) // default 1 hour

No Cache

Disable caching entirely:

cachePolicy := staticweb.NoCache()

Fallback Strategies

HTML Fallback

Serve index.html for non-asset requests (SPA routing):

fallback := staticweb.HTMLFallback("index.html")

Extension-Based Fallback

Skip fallback for known static assets:

fallback := staticweb.DefaultExtensionFallback("index.html")

Custom extensions:

staticExts := []string{".js", ".css", ".png", ".jpg"}
fallback := staticweb.ExtensionFallback(staticExts, "index.html")

Configuration

Service Configuration

config := &staticweb.ServiceConfig{
    DefaultCacheTime: 3600,
    DefaultMIMETypes: map[string]string{
        ".webp": "image/webp",
        ".wasm": "application/wasm",
    },
}

service := staticweb.NewService(config)

Mount Configuration

service.Mount(staticweb.MountConfig{
    URLPrefix:        "/static",
    Provider:         provider,
    CachePolicy:      cachePolicy,      // Optional
    MIMEResolver:     mimeResolver,     // Optional
    FallbackStrategy: fallbackStrategy, // Optional
})

Advanced Usage

Multiple Mount Points

Serve different directories at different URL prefixes with different policies:

service := staticweb.NewService(nil)

// Long-lived assets
assetsProvider, _ := staticweb.LocalProvider("./assets")
service.Mount(staticweb.MountConfig{
    URLPrefix:   "/assets",
    Provider:    assetsProvider,
    CachePolicy: staticweb.SimpleCache(604800), // 1 week
})

// Frequently updated HTML
htmlProvider, _ := staticweb.LocalProvider("./public")
service.Mount(staticweb.MountConfig{
    URLPrefix:   "/",
    Provider:    htmlProvider,
    CachePolicy: staticweb.SimpleCache(300), // 5 minutes
})

Custom MIME Types

mimeResolver := staticweb.DefaultMIMEResolver()
mimeResolver.RegisterMIMEType(".webp", "image/webp")
mimeResolver.RegisterMIMEType(".wasm", "application/wasm")

service.Mount(staticweb.MountConfig{
    URLPrefix:    "/static",
    Provider:     provider,
    MIMEResolver: mimeResolver,
})

Resource Cleanup

Always close the service when done:

service := staticweb.NewService(nil)
defer service.Close()

// ... mount and use service ...

Or unmount individual mount points:

service.Unmount("/static")

Reloading/Refreshing Content

Reload providers to pick up changes from the underlying filesystem. This is particularly useful for zip files in development:

// When zip file or directory contents change
err := service.Reload()
if err != nil {
    log.Printf("Failed to reload: %v", err)
}

Providers that support reloading:

  • ZipFSProvider: Reopens the zip file to pick up changes
  • LocalFSProvider: Refreshes the directory view (automatically picks up changes)
  • EmbedFSProvider: Not reloadable (embedded at compile time)

You can also reload individual providers:

if reloadable, ok := provider.(staticweb.ReloadableProvider); ok {
    err := reloadable.Reload()
    if err != nil {
        log.Printf("Failed to reload: %v", err)
    }
}

Development Workflow Example:

service := staticweb.NewService(nil)

provider, _ := staticweb.ZipProvider("./dist.zip")
service.Mount(staticweb.MountConfig{
    URLPrefix: "/app",
    Provider:  provider,
})

// In development, reload when dist.zip is rebuilt
go func() {
    watcher := fsnotify.NewWatcher()
    watcher.Add("./dist.zip")

    for range watcher.Events {
        log.Println("Reloading static files...")
        if err := service.Reload(); err != nil {
            log.Printf("Reload failed: %v", err)
        }
    }
}()

Router Integration

Gorilla Mux

router := mux.NewRouter()
router.HandleFunc("/api/users", usersHandler)
router.PathPrefix("/").Handler(service.Handler())

Standard http.ServeMux

http.Handle("/api/", apiHandler)
http.Handle("/", service.Handler())

BunRouter

router.GET("/api/users", usersHandler)
router.GET("/*path", bunrouter.HTTPHandlerFunc(service.Handler()))

Architecture

Core Interfaces

FileSystemProvider

Abstracts the source of files:

type FileSystemProvider interface {
    Open(name string) (fs.File, error)
    Close() error
    Type() string
}

Implementations:

  • LocalFSProvider - Local directories
  • ZipFSProvider - Zip archives
  • EmbedFSProvider - Embedded filesystems

CachePolicy

Defines caching behavior:

type CachePolicy interface {
    GetCacheTime(path string) int
    GetCacheHeaders(path string) map[string]string
}

Implementations:

  • SimpleCachePolicy - Single TTL
  • ExtensionBasedCachePolicy - Per-extension TTL
  • NoCachePolicy - Disable caching

FallbackStrategy

Handles missing files:

type FallbackStrategy interface {
    ShouldFallback(path string) bool
    GetFallbackPath(path string) string
}

Implementations:

  • NoFallback - Return 404
  • HTMLFallbackStrategy - SPA routing
  • ExtensionBasedFallback - Skip known assets

MIMETypeResolver

Determines Content-Type:

type MIMETypeResolver interface {
    GetMIMEType(path string) string
    RegisterMIMEType(extension, mimeType string)
}

Implementations:

  • DefaultMIMEResolver - Common web types
  • ConfigurableMIMEResolver - Custom mappings

Testing

Mock Providers

import staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing"

provider := staticwebtesting.NewMockProvider(map[string][]byte{
    "index.html": []byte("<html>test</html>"),
    "app.js":     []byte("console.log('test')"),
})

service.Mount(staticweb.MountConfig{
    URLPrefix: "/",
    Provider:  provider,
})

Test Helpers

req := httptest.NewRequest("GET", "/index.html", nil)
rec := httptest.NewRecorder()

service.Handler().ServeHTTP(rec, req)

// Assert response
assert.Equal(t, 200, rec.Code)

Future Features

The interface-driven design allows for easy extensibility:

Planned Providers

  • HTTPFSProvider: Fetch files from remote HTTP servers with local caching
  • S3FSProvider: Serve files from S3-compatible storage
  • CompositeProvider: Fallback chain across multiple providers
  • MemoryProvider: In-memory filesystem for testing

Planned Policies

  • TimedCachePolicy: Different cache times by time of day
  • ConditionalCachePolicy: Smart cache based on file size/type
  • RegexFallbackStrategy: Pattern-based routing

License

See the main repository for license information.

Contributing

Contributions are welcome! The interface-driven design makes it easy to add new providers and policies without modifying existing code.