18 KiB
StaticWeb Package Interface-Driven Refactoring Plan
Overview
Refactor pkg/server/staticweb to be interface-driven, router-agnostic, and maintainable. This is a breaking change that replaces the existing API with a cleaner design.
User Requirements
- ✅ Work with any server (not just Gorilla mux)
- ✅ Serve static files from zip files or directories
- ✅ Support embedded, local filesystems
- ✅ Interface-driven and maintainable architecture
- ✅ Struct-based configuration
- ✅ Breaking changes acceptable
- 🔮 Remote HTTP/HTTPS and S3 support (future feature)
Design Principles
- Interface-first: Define clear interfaces for all abstractions
- Composition over inheritance: Combine small, focused components
- Router-agnostic: Return standard
http.Handlerfor universal compatibility - Configurable policies: Extract hardcoded behavior into pluggable strategies
- Resource safety: Proper lifecycle management with
Close()methods - Testability: Mock-friendly interfaces with clear boundaries
- Extensibility: Easy to add new providers (HTTP, S3, etc.) in future
Architecture Overview
Core Interfaces (pkg/server/staticweb/interfaces.go)
// FileSystemProvider abstracts file sources (local, zip, embedded, future: http, s3)
type FileSystemProvider interface {
Open(name string) (fs.File, error)
Close() error
Type() string
}
// CachePolicy defines caching behavior
type CachePolicy interface {
GetCacheTime(path string) int
GetCacheHeaders(path string) map[string]string
}
// MIMETypeResolver determines content types
type MIMETypeResolver interface {
GetMIMEType(path string) string
RegisterMIMEType(extension, mimeType string)
}
// FallbackStrategy handles missing files
type FallbackStrategy interface {
ShouldFallback(path string) bool
GetFallbackPath(path string) string
}
// StaticFileService manages mount points
type StaticFileService interface {
Mount(config MountConfig) error
Unmount(urlPrefix string) error
ListMounts() []string
Reload() error
Close() error
Handler() http.Handler // Router-agnostic integration
}
Configuration (pkg/server/staticweb/config.go)
// MountConfig configures a single mount point
type MountConfig struct {
URLPrefix string
Provider FileSystemProvider
CachePolicy CachePolicy // Optional, uses default if nil
MIMEResolver MIMETypeResolver // Optional, uses default if nil
FallbackStrategy FallbackStrategy // Optional, no fallback if nil
}
// ServiceConfig configures the service
type ServiceConfig struct {
DefaultCacheTime int // Default: 48 hours
DefaultMIMETypes map[string]string // Additional MIME types
}
Implementation Plan
Step 1: Create Core Interfaces
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/interfaces.go (NEW)
Define all interfaces listed above. This establishes the contract for all components.
Step 2: Implement Default Policies
Directory: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/policies/ (NEW)
File: policies/cache.go
SimpleCachePolicy- Single TTL for all filesExtensionBasedCachePolicy- Different TTL per file extensionNoCachePolicy- Disables caching
File: policies/mime.go
DefaultMIMEResolver- Standard web MIME types + stdlibConfigurableMIMEResolver- User-defined mappings- Migrate hardcoded MIME types from
InitMimeTypes()(lines 60-76)
File: policies/fallback.go
NoFallback- Returns 404 for missing filesHTMLFallbackStrategy- SPA routing (serves index.html)ExtensionBasedFallback- Current behavior (checks extensions)- Migrate logic from
StaticHTMLFallbackHandler()(lines 241-285)
Step 3: Implement FileSystem Providers
Directory: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/providers/ (NEW)
File: providers/local.go
type LocalFSProvider struct {
path string
fs fs.FS
}
func NewLocalFSProvider(path string) (*LocalFSProvider, error)
func (l *LocalFSProvider) Open(name string) (fs.File, error)
func (l *LocalFSProvider) Close() error
func (l *LocalFSProvider) Type() string
File: providers/zip.go
type ZipFSProvider struct {
zipPath string
zipReader *zip.ReadCloser
zipFS *zipfs.ZipFS
mu sync.RWMutex
}
func NewZipFSProvider(zipPath string) (*ZipFSProvider, error)
func (z *ZipFSProvider) Open(name string) (fs.File, error)
func (z *ZipFSProvider) Close() error
func (z *ZipFSProvider) Type() string
- Integrates with existing
pkg/server/zipfs/zipfs.go - Manages zip file lifecycle properly
File: providers/embed.go
type EmbedFSProvider struct {
embedFS *embed.FS
zipFile string // Optional: path within embedded FS to zip file
zipReader *zip.ReadCloser
fs fs.FS
mu sync.RWMutex
}
func NewEmbedFSProvider(embedFS *embed.FS, zipFile string) (*EmbedFSProvider, error)
func (e *EmbedFSProvider) Open(name string) (fs.File, error)
func (e *EmbedFSProvider) Close() error
func (e *EmbedFSProvider) Type() string
Step 4: Implement Mount Point
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/mount.go (NEW)
type mountPoint struct {
urlPrefix string
provider FileSystemProvider
cachePolicy CachePolicy
mimeResolver MIMETypeResolver
fallbackStrategy FallbackStrategy
}
func newMountPoint(config MountConfig, defaults *ServiceConfig) (*mountPoint, error)
func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request)
func (m *mountPoint) Close() error
Key behaviors:
- Strips URL prefix before passing to provider
- Applies cache headers via
CachePolicy - Sets Content-Type via
MIMETypeResolver - Falls back via
FallbackStrategyif file not found - Integrates with
http.FileServer()for actual serving
Step 5: Implement Service
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/service.go (NEW)
type service struct {
mounts map[string]*mountPoint // urlPrefix -> mount
config *ServiceConfig
mu sync.RWMutex
}
func NewService(config *ServiceConfig) StaticFileService
func (s *service) Mount(config MountConfig) error
func (s *service) Unmount(urlPrefix string) error
func (s *service) ListMounts() []string
func (s *service) Reload() error
func (s *service) Close() error
func (s *service) Handler() http.Handler
Handler Implementation:
- Performs longest-prefix matching to find mount point
- Delegates to mount point's
ServeHTTP() - Returns silently if no match (allows API routes to handle)
- Thread-safe with
sync.RWMutex
Step 6: Create Configuration Helpers
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/config.go (NEW)
// Default configurations
func DefaultServiceConfig() *ServiceConfig
func DefaultCachePolicy() CachePolicy
func DefaultMIMEResolver() MIMETypeResolver
// Helper constructors
func LocalProvider(path string) FileSystemProvider
func ZipProvider(zipPath string) FileSystemProvider
func EmbedProvider(embedFS *embed.FS, zipFile string) FileSystemProvider
// Policy constructors
func SimpleCache(seconds int) CachePolicy
func ExtensionCache(rules map[string]int) CachePolicy
func HTMLFallback(indexFile string) FallbackStrategy
func ExtensionFallback(staticExtensions []string) FallbackStrategy
Step 7: Update/Remove Existing Files
REMOVE: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/staticweb.go
- All functionality migrated to new interface-based design
- No backward compatibility needed per user request
KEEP: /home/hein/hein/dev/ResolveSpec/pkg/server/zipfs/zipfs.go
- Still used by
ZipFSProvider - Already implements
fs.FSinterface correctly
Step 8: Create Examples and Tests
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/example_test.go (NEW)
func ExampleService_basic() { /* Serve local directory */ }
func ExampleService_spa() { /* SPA with fallback */ }
func ExampleService_multiple() { /* Multiple mount points */ }
func ExampleService_zip() { /* Serve from zip file */ }
func ExampleService_embedded() { /* Serve from embedded zip */ }
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/service_test.go (NEW)
- Test mount/unmount operations
- Test longest-prefix matching
- Test concurrent access
- Test resource cleanup
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/providers/providers_test.go (NEW)
- Test each provider implementation
- Test resource cleanup
- Test error handling
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/testing/mocks.go (NEW)
MockFileSystemProvider- In-memory file storageMockCachePolicy- Configurable cache behaviorMockMIMEResolver- Custom MIME mappings- Test helpers for common scenarios
Step 9: Create Documentation
File: /home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/README.md (NEW)
Document:
- Quick start examples
- Interface overview
- Provider implementations
- Policy customization
- Router integration patterns
- Migration guide from old API
- Future features roadmap
Key Improvements Over Current Implementation
1. Router-Agnostic Design
Before: Coupled to Gorilla mux via RegisterRoutes(*mux.Router)
After: Returns http.Handler, works with any router
// Works with Gorilla Mux
muxRouter.PathPrefix("/").Handler(service.Handler())
// Works with standard http.ServeMux
http.Handle("/", service.Handler())
// Works with any http.Handler-compatible router
2. Configurable Behaviors
Before: Hardcoded MIME types, cache times, file extensions After: Pluggable policies
// Custom cache per file type
cachePolicy := ExtensionCache(map[string]int{
".html": 3600, // 1 hour
".js": 86400, // 1 day
".css": 86400, // 1 day
".png": 604800, // 1 week
})
// Custom fallback logic
fallback := HTMLFallback("index.html")
service.Mount(MountConfig{
URLPrefix: "/",
Provider: LocalProvider("./dist"),
CachePolicy: cachePolicy,
FallbackStrategy: fallback,
})
3. Better Resource Management
Before: Manual zip file cleanup, easy to leak resources
After: Proper lifecycle with Close() on all components
defer service.Close() // Cleans up all providers
4. Testability
Before: Hard to test, coupled to filesystem After: Mock providers for testing
mockProvider := testing.NewInMemoryProvider(map[string][]byte{
"index.html": []byte("<html>test</html>"),
})
service.Mount(MountConfig{
URLPrefix: "/",
Provider: mockProvider,
})
5. Extensibility
Before: Need to modify code to support new file sources
After: Implement FileSystemProvider interface
// Future: Add HTTP provider without changing core code
type HTTPFSProvider struct { /* ... */ }
func (h *HTTPFSProvider) Open(name string) (fs.File, error) { /* ... */ }
func (h *HTTPFSProvider) Close() error { /* ... */ }
func (h *HTTPFSProvider) Type() string { return "http" }
Future Features (To Implement Later)
Remote HTTP/HTTPS Provider
File: providers/http.go (FUTURE)
Serve static files from remote HTTP servers with local caching:
type HTTPFSProvider struct {
baseURL string
httpClient *http.Client
cache LocalCache // Optional disk/memory cache
cacheTTL time.Duration
mu sync.RWMutex
}
// Example usage
service.Mount(MountConfig{
URLPrefix: "/cdn",
Provider: HTTPProvider("https://cdn.example.com/assets"),
})
Features:
- Fetch files from remote URLs on-demand
- Local cache to reduce remote requests
- Configurable TTL and cache eviction
- HEAD request support for metadata
- Retry logic and timeout handling
- Support for authentication headers
S3-Compatible Provider
File: providers/s3.go (FUTURE)
Serve static files from S3, MinIO, or S3-compatible storage:
type S3FSProvider struct {
bucket string
prefix string
region string
client *s3.Client
cache LocalCache
mu sync.RWMutex
}
// Example usage
service.Mount(MountConfig{
URLPrefix: "/media",
Provider: S3Provider("my-bucket", "static/", "us-east-1"),
})
Features:
- List and fetch objects from S3 buckets
- Support for AWS S3, MinIO, DigitalOcean Spaces, etc.
- IAM role or credential-based authentication
- Optional local caching layer
- Efficient metadata retrieval
- Support for presigned URLs
Other Future Providers
- GitProvider: Serve files from Git repositories
- MemoryProvider: In-memory filesystem for testing/temporary files
- ProxyProvider: Proxy to another static file server
- CompositeProvider: Fallback chain across multiple providers
Advanced Cache Policies (FUTURE)
- TimedCachePolicy: Different cache times by time of day
- UserAgentCachePolicy: Cache based on client type
- ConditionalCachePolicy: Smart cache based on file size/type
- DistributedCachePolicy: Shared cache across service instances
Advanced Fallback Strategies (FUTURE)
- RegexFallbackStrategy: Pattern-based routing
- I18nFallbackStrategy: Language-based file resolution
- VersionedFallbackStrategy: A/B testing support
Critical Files Summary
Files to CREATE (in order):
pkg/server/staticweb/interfaces.go- Core contractspkg/server/staticweb/config.go- Configuration structs and helperspkg/server/staticweb/policies/cache.go- Cache policy implementationspkg/server/staticweb/policies/mime.go- MIME resolver implementationspkg/server/staticweb/policies/fallback.go- Fallback strategy implementationspkg/server/staticweb/providers/local.go- Local directory providerpkg/server/staticweb/providers/zip.go- Zip file providerpkg/server/staticweb/providers/embed.go- Embedded filesystem providerpkg/server/staticweb/mount.go- Mount point implementationpkg/server/staticweb/service.go- Main service implementationpkg/server/staticweb/testing/mocks.go- Test helperspkg/server/staticweb/service_test.go- Service testspkg/server/staticweb/providers/providers_test.go- Provider testspkg/server/staticweb/example_test.go- Example codepkg/server/staticweb/README.md- Documentation
Files to REMOVE:
pkg/server/staticweb/staticweb.go- Replaced by new design
Files to KEEP:
pkg/server/zipfs/zipfs.go- Used by ZipFSProvider
Files for FUTURE (not in this refactoring):
pkg/server/staticweb/providers/http.go- HTTP/HTTPS remote providerpkg/server/staticweb/providers/s3.go- S3-compatible storage providerpkg/server/staticweb/providers/composite.go- Fallback chain provider
Example Usage After Refactoring
Basic Static Site
service := staticweb.NewService(nil) // Use defaults
err := service.Mount(staticweb.MountConfig{
URLPrefix: "/static",
Provider: staticweb.LocalProvider("./public"),
})
muxRouter.PathPrefix("/").Handler(service.Handler())
SPA with API Routes
service := staticweb.NewService(nil)
service.Mount(staticweb.MountConfig{
URLPrefix: "/",
Provider: staticweb.LocalProvider("./dist"),
FallbackStrategy: staticweb.HTMLFallback("index.html"),
})
// API routes take precedence (registered first)
muxRouter.HandleFunc("/api/users", usersHandler)
muxRouter.HandleFunc("/api/posts", postsHandler)
// Static files handle all other routes
muxRouter.PathPrefix("/").Handler(service.Handler())
Multiple Mount Points with Different Policies
service := staticweb.NewService(&staticweb.ServiceConfig{
DefaultCacheTime: 3600,
})
// Assets with long cache
service.Mount(staticweb.MountConfig{
URLPrefix: "/assets",
Provider: staticweb.LocalProvider("./assets"),
CachePolicy: staticweb.SimpleCache(604800), // 1 week
})
// HTML with short cache
service.Mount(staticweb.MountConfig{
URLPrefix: "/",
Provider: staticweb.LocalProvider("./public"),
CachePolicy: staticweb.SimpleCache(300), // 5 minutes
})
router.PathPrefix("/").Handler(service.Handler())
Embedded Files from Zip
//go:embed assets.zip
var assetsZip embed.FS
service := staticweb.NewService(nil)
service.Mount(staticweb.MountConfig{
URLPrefix: "/static",
Provider: staticweb.EmbedProvider(&assetsZip, "assets.zip"),
})
router.PathPrefix("/").Handler(service.Handler())
Future: CDN Fallback (when HTTP provider is implemented)
// Primary CDN with local fallback
service.Mount(staticweb.MountConfig{
URLPrefix: "/static",
Provider: staticweb.CompositeProvider(
staticweb.HTTPProvider("https://cdn.example.com/assets"),
staticweb.LocalProvider("./public/assets"),
),
})
Testing Strategy
Unit Tests
- Each provider implementation independently
- Each policy implementation independently
- Mount point request handling
- Service mount/unmount operations
Integration Tests
- Full request flow through service
- Multiple mount points
- Longest-prefix matching
- Resource cleanup
Example Tests
- Executable examples in
example_test.go - Demonstrate common usage patterns
Migration Impact
Breaking Changes
- Complete API redesign (acceptable per user)
- Package not currently used in codebase (no migration needed)
- New consumers will use new API from start
Future Extensibility
The interface-driven design allows future additions without breaking changes:
- Add HTTPFSProvider by implementing
FileSystemProvider - Add S3FSProvider by implementing
FileSystemProvider - Add custom cache policies by implementing
CachePolicy - Add custom fallback strategies by implementing
FallbackStrategy
Implementation Order
- Interfaces (foundation)
- Configuration (API surface)
- Policies (pluggable behavior)
- Providers (filesystem abstraction)
- Mount Point (request handling)
- Service (orchestration)
- Tests (validation)
- Documentation (usage)
- Remove old code (cleanup)
This order ensures each layer builds on tested, working components.