Files
ResolveSpec/pkg/server/staticweb/README.md

9.3 KiB

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.