staticweb

package
v1.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Dec 31, 2025 License: MIT Imports: 11 Imported by: 0

README

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.

Documentation

Overview

Example (Basic)

Example_basic demonstrates serving files from a local directory.

package main

import (
	"fmt"

	"github.com/bitechdev/ResolveSpec/pkg/server/staticweb"
	staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing"
	"github.com/gorilla/mux"
)

func main() {
	service := staticweb.NewService(nil)

	// Using mock provider for example purposes
	provider := staticwebtesting.NewMockProvider(map[string][]byte{
		"index.html": []byte("<html>test</html>"),
	})

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

	router := mux.NewRouter()
	router.PathPrefix("/").Handler(service.Handler())

	fmt.Println("Serving files from ./public at /static")
}
Output:

Serving files from ./public at /static
Example (ExtensionCache)

Example_extensionCache demonstrates extension-based caching.

package main

import (
	"fmt"

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

func main() {
	service := staticweb.NewService(nil)

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

	// Different cache times per file type
	cacheRules := map[string]int{
		".html": 3600,   // 1 hour
		".js":   86400,  // 1 day
		".css":  86400,  // 1 day
		".png":  604800, // 1 week
	}

	service.Mount(staticweb.MountConfig{
		URLPrefix:   "/",
		Provider:    provider,
		CachePolicy: staticweb.ExtensionCache(cacheRules, 3600), // default 1 hour
	})

	fmt.Println("Extension-based caching configured")
}
Output:

Extension-based caching configured
Example (Multiple)

Example_multiple demonstrates multiple mount points with different policies.

package main

import (
	"fmt"

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

func main() {
	service := staticweb.NewService(&staticweb.ServiceConfig{
		DefaultCacheTime: 3600,
	})

	// Assets with long cache (using mock for example)
	assetsProvider := staticwebtesting.NewMockProvider(map[string][]byte{
		"app.js": []byte("console.log('test')"),
	})
	service.Mount(staticweb.MountConfig{
		URLPrefix:   "/assets",
		Provider:    assetsProvider,
		CachePolicy: staticweb.SimpleCache(604800), // 1 week
	})

	// HTML with short cache (using mock for example)
	htmlProvider := staticwebtesting.NewMockProvider(map[string][]byte{
		"index.html": []byte("<html>test</html>"),
	})
	service.Mount(staticweb.MountConfig{
		URLPrefix:   "/",
		Provider:    htmlProvider,
		CachePolicy: staticweb.SimpleCache(300), // 5 minutes
	})

	fmt.Println("Multiple mount points configured")
}
Output:

Multiple mount points configured
Example (Reload)

Example_reload demonstrates reloading content when files change.

package main

import (
	"fmt"

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

func main() {
	service := staticweb.NewService(nil)

	// Create a provider
	provider := staticwebtesting.NewMockProvider(map[string][]byte{
		"version.txt": []byte("v1.0.0"),
	})

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

	// Simulate updating the file
	provider.AddFile("version.txt", []byte("v2.0.0"))

	// Reload to pick up changes (in real usage with zip files)
	err := service.Reload()
	if err != nil {
		fmt.Printf("Failed to reload: %v\n", err)
	} else {
		fmt.Println("Successfully reloaded static files")
	}

}
Output:

Successfully reloaded static files
Example (ReloadZip)

Example_reloadZip demonstrates reloading a zip file provider.

package main

import (
	"fmt"

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

func main() {
	service := staticweb.NewService(nil)

	// In production, you would use:
	// provider, _ := staticweb.ZipProvider("./dist.zip")
	// For this example, we use a mock
	provider := staticwebtesting.NewMockProvider(map[string][]byte{
		"app.js": []byte("console.log('v1')"),
	})

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

	fmt.Println("Serving from zip file")

	// When the zip file is updated, call Reload()
	// service.Reload()

}
Output:

Serving from zip file
Example (Spa)

Example_spa demonstrates an SPA with HTML fallback routing.

package main

import (
	"fmt"
	"net/http"

	"github.com/bitechdev/ResolveSpec/pkg/server/staticweb"
	staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing"
	"github.com/gorilla/mux"
)

func main() {
	service := staticweb.NewService(nil)

	// Using mock provider for example purposes
	provider := staticwebtesting.NewMockProvider(map[string][]byte{
		"index.html": []byte("<html>app</html>"),
	})

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

	router := mux.NewRouter()

	// API routes take precedence
	router.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("users"))
	})

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

	fmt.Println("SPA with fallback to index.html")
}
Output:

SPA with fallback to index.html
Example (Zip)

Example_zip demonstrates serving from a zip file (concept).

package main

import (
	"fmt"

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

func main() {
	service := staticweb.NewService(nil)

	// For actual usage, you would use:
	// provider, err := staticweb.ZipProvider("./static.zip")
	// For this example, we use a mock
	provider := staticwebtesting.NewMockProvider(map[string][]byte{
		"file.txt": []byte("content"),
	})

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

	fmt.Println("Serving from zip file")
}
Output:

Serving from zip file

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CachePolicy

type CachePolicy interface {
	// GetCacheTime returns the cache duration in seconds for the given path.
	// A value of 0 means no caching.
	// A negative value can be used to indicate browser should revalidate.
	GetCacheTime(path string) int

	// GetCacheHeaders returns additional cache-related HTTP headers for the given path.
	// Common headers include "Cache-Control", "Expires", "ETag", etc.
	// Returns nil if no additional headers are needed.
	GetCacheHeaders(path string) map[string]string
}

CachePolicy defines how files should be cached by browsers and proxies. Implementations must be safe for concurrent use.

func ExtensionCache

func ExtensionCache(rules map[string]int, defaultTime int) CachePolicy

ExtensionCache creates an extension-based cache policy. rules maps file extensions (with leading dot) to cache times in seconds. defaultTime is used for files that don't match any rule.

func NoCache

func NoCache() CachePolicy

NoCache creates a cache policy that disables all caching.

func SimpleCache

func SimpleCache(seconds int) CachePolicy

SimpleCache creates a simple cache policy with the given TTL in seconds.

type FallbackStrategy

type FallbackStrategy interface {
	// ShouldFallback determines if a fallback should be attempted for the given path.
	// Returns true if the request should be handled by fallback logic.
	ShouldFallback(path string) bool

	// GetFallbackPath returns the path to serve instead of the originally requested path.
	// This is only called if ShouldFallback returns true.
	GetFallbackPath(path string) string
}

FallbackStrategy handles requests for files that don't exist. This is commonly used for Single Page Applications (SPAs) that use client-side routing. Implementations must be safe for concurrent use.

func DefaultExtensionFallback

func DefaultExtensionFallback(fallbackPath string) FallbackStrategy

DefaultExtensionFallback creates an extension-based fallback with common web asset extensions.

func ExtensionFallback

func ExtensionFallback(staticExtensions []string, fallbackPath string) FallbackStrategy

ExtensionFallback creates an extension-based fallback strategy. staticExtensions is a list of file extensions that should NOT use fallback. fallbackPath is the file to serve when fallback is triggered.

func HTMLFallback

func HTMLFallback(indexFile string) FallbackStrategy

HTMLFallback creates a fallback strategy for SPAs that serves the given index file.

type FileSystemProvider

type FileSystemProvider interface {
	// Open opens the named file.
	// The name is always a slash-separated path relative to the filesystem root.
	Open(name string) (fs.File, error)

	// Close releases any resources held by the provider.
	// After Close is called, the provider should not be used.
	Close() error

	// Type returns the provider type (e.g., "local", "zip", "embed", "http", "s3").
	// This is primarily for debugging and logging purposes.
	Type() string
}

FileSystemProvider abstracts the source of files (local, zip, embedded, future: http, s3) Implementations must be safe for concurrent use.

func EmbedProvider

func EmbedProvider(embedFS *embed.FS, zipFile string) (FileSystemProvider, error)

EmbedProvider creates a FileSystemProvider for an embedded filesystem. If zipFile is empty, the embedded FS is used directly. If zipFile is specified, it's treated as a path to a zip file within the embedded FS. The embedFS parameter can be any fs.FS, but is typically *embed.FS.

func LocalProvider

func LocalProvider(path string) (FileSystemProvider, error)

LocalProvider creates a FileSystemProvider for a local directory.

func ZipProvider

func ZipProvider(zipPath string) (FileSystemProvider, error)

ZipProvider creates a FileSystemProvider for a zip file.

type MIMETypeResolver

type MIMETypeResolver interface {
	// GetMIMEType returns the MIME type for the given file path.
	// Returns empty string if the MIME type cannot be determined.
	GetMIMEType(path string) string

	// RegisterMIMEType registers a custom MIME type for the given file extension.
	// The extension should include the leading dot (e.g., ".webp").
	RegisterMIMEType(extension, mimeType string)
}

MIMETypeResolver determines the Content-Type for files. Implementations must be safe for concurrent use.

func DefaultMIMEResolver

func DefaultMIMEResolver() MIMETypeResolver

DefaultMIMEResolver creates a MIME resolver with common web file types.

type MountConfig

type MountConfig struct {
	// URLPrefix is the URL path prefix where the filesystem should be mounted.
	// Must start with "/" (e.g., "/static", "/", "/assets").
	// Requests starting with this prefix will be handled by this mount point.
	URLPrefix string

	// Provider is the filesystem provider that supplies the files.
	// Required.
	Provider FileSystemProvider

	// CachePolicy determines how files should be cached.
	// If nil, the service's default cache policy is used.
	CachePolicy CachePolicy

	// MIMEResolver determines Content-Type headers for files.
	// If nil, the service's default MIME resolver is used.
	MIMEResolver MIMETypeResolver

	// FallbackStrategy handles requests for missing files.
	// If nil, no fallback is performed and 404 responses are returned.
	FallbackStrategy FallbackStrategy
}

MountConfig configures a single mount point. A mount point connects a URL prefix to a filesystem provider with optional policies.

type ReloadableProvider

type ReloadableProvider interface {
	FileSystemProvider

	// Reload refreshes the provider's content from the underlying source.
	// For zip files, this reopens the zip archive.
	// For local directories, this refreshes the filesystem view.
	// Returns an error if the reload fails.
	Reload() error
}

ReloadableProvider is an optional interface that providers can implement to support reloading/refreshing their content. This is useful for development workflows where the underlying files may change.

type ServiceConfig

type ServiceConfig struct {
	// DefaultCacheTime is the default cache duration in seconds.
	// Used when a mount point doesn't specify a custom CachePolicy.
	// Default: 172800 (48 hours)
	DefaultCacheTime int

	// DefaultMIMETypes is a map of file extensions to MIME types.
	// These are added to the default MIME resolver.
	// Extensions should include the leading dot (e.g., ".webp").
	DefaultMIMETypes map[string]string
}

ServiceConfig configures the static file service.

func DefaultServiceConfig

func DefaultServiceConfig() *ServiceConfig

DefaultServiceConfig returns a ServiceConfig with sensible defaults.

func (*ServiceConfig) Validate

func (c *ServiceConfig) Validate() error

Validate checks if the ServiceConfig is valid.

type StaticFileService

type StaticFileService interface {
	// Mount adds a new mount point with the given configuration.
	// Returns an error if the URLPrefix is already mounted or if the config is invalid.
	Mount(config MountConfig) error

	// Unmount removes the mount point at the given URL prefix.
	// Returns an error if no mount point exists at that prefix.
	// Automatically calls Close() on the provider to release resources.
	Unmount(urlPrefix string) error

	// ListMounts returns a sorted list of all mounted URL prefixes.
	ListMounts() []string

	// Reload reinitializes all filesystem providers.
	// This can be used to pick up changes in the underlying filesystems.
	// Not all providers may support reloading.
	Reload() error

	// Close releases all resources held by the service and all mounted providers.
	// After Close is called, the service should not be used.
	Close() error

	// Handler returns an http.Handler that serves static files from all mount points.
	// The handler performs longest-prefix matching to find the appropriate mount point.
	// If no mount point matches, the handler returns without writing a response,
	// allowing other handlers (like API routes) to process the request.
	Handler() http.Handler
}

StaticFileService manages multiple mount points and serves static files. The service is safe for concurrent use.

func NewService

func NewService(config *ServiceConfig) StaticFileService

NewService creates a new static file service with the given configuration. If config is nil, default configuration is used.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL