diff --git a/pkg/server/staticweb/README.md b/pkg/server/staticweb/README.md new file mode 100644 index 0000000..5c11d28 --- /dev/null +++ b/pkg/server/staticweb/README.md @@ -0,0 +1,439 @@ +# 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 + +```bash +go get github.com/bitechdev/ResolveSpec/pkg/server/staticweb +``` + +## Quick Start + +### Basic Usage + +Serve files from a local directory: + +```go +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: + +```go +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: + +```go +provider, err := staticweb.LocalProvider("/var/www/static") +``` + +### Zip File + +Serve files from a zip archive: + +```go +provider, err := staticweb.ZipProvider("./static.zip") +``` + +### Embedded Filesystem + +Serve files from Go's embedded filesystem: + +```go +//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: + +```go +cachePolicy := staticweb.SimpleCache(3600) // 1 hour +``` + +### Extension-Based Cache + +Different TTLs per file type: + +```go +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: + +```go +cachePolicy := staticweb.NoCache() +``` + +## Fallback Strategies + +### HTML Fallback + +Serve index.html for non-asset requests (SPA routing): + +```go +fallback := staticweb.HTMLFallback("index.html") +``` + +### Extension-Based Fallback + +Skip fallback for known static assets: + +```go +fallback := staticweb.DefaultExtensionFallback("index.html") +``` + +Custom extensions: + +```go +staticExts := []string{".js", ".css", ".png", ".jpg"} +fallback := staticweb.ExtensionFallback(staticExts, "index.html") +``` + +## Configuration + +### Service Configuration + +```go +config := &staticweb.ServiceConfig{ + DefaultCacheTime: 3600, + DefaultMIMETypes: map[string]string{ + ".webp": "image/webp", + ".wasm": "application/wasm", + }, +} + +service := staticweb.NewService(config) +``` + +### Mount Configuration + +```go +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: + +```go +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 + +```go +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: + +```go +service := staticweb.NewService(nil) +defer service.Close() + +// ... mount and use service ... +``` + +Or unmount individual mount points: + +```go +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: + +```go +// 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: + +```go +if reloadable, ok := provider.(staticweb.ReloadableProvider); ok { + err := reloadable.Reload() + if err != nil { + log.Printf("Failed to reload: %v", err) + } +} +``` + +**Development Workflow Example:** + +```go +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 + +```go +router := mux.NewRouter() +router.HandleFunc("/api/users", usersHandler) +router.PathPrefix("/").Handler(service.Handler()) +``` + +### Standard http.ServeMux + +```go +http.Handle("/api/", apiHandler) +http.Handle("/", service.Handler()) +``` + +### BunRouter + +```go +router.GET("/api/users", usersHandler) +router.GET("/*path", bunrouter.HTTPHandlerFunc(service.Handler())) +``` + +## Architecture + +### Core Interfaces + +#### FileSystemProvider + +Abstracts the source of files: + +```go +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: + +```go +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: + +```go +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: + +```go +type MIMETypeResolver interface { + GetMIMEType(path string) string + RegisterMIMEType(extension, mimeType string) +} +``` + +Implementations: +- `DefaultMIMEResolver` - Common web types +- `ConfigurableMIMEResolver` - Custom mappings + +## Testing + +### Mock Providers + +```go +import staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing" + +provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("test"), + "app.js": []byte("console.log('test')"), +}) + +service.Mount(staticweb.MountConfig{ + URLPrefix: "/", + Provider: provider, +}) +``` + +### Test Helpers + +```go +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. diff --git a/pkg/server/staticweb/config.go b/pkg/server/staticweb/config.go new file mode 100644 index 0000000..1249b88 --- /dev/null +++ b/pkg/server/staticweb/config.go @@ -0,0 +1,99 @@ +package staticweb + +import ( + "embed" + "fmt" + + "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/policies" + "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/providers" +) + +// ServiceConfig configures the static file service. +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 +} + +// DefaultServiceConfig returns a ServiceConfig with sensible defaults. +func DefaultServiceConfig() *ServiceConfig { + return &ServiceConfig{ + DefaultCacheTime: 172800, // 48 hours + DefaultMIMETypes: make(map[string]string), + } +} + +// Validate checks if the ServiceConfig is valid. +func (c *ServiceConfig) Validate() error { + if c.DefaultCacheTime < 0 { + return fmt.Errorf("DefaultCacheTime cannot be negative") + } + return nil +} + +// Helper constructor functions for providers + +// LocalProvider creates a FileSystemProvider for a local directory. +func LocalProvider(path string) (FileSystemProvider, error) { + return providers.NewLocalFSProvider(path) +} + +// ZipProvider creates a FileSystemProvider for a zip file. +func ZipProvider(zipPath string) (FileSystemProvider, error) { + return providers.NewZipFSProvider(zipPath) +} + +// 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 EmbedProvider(embedFS *embed.FS, zipFile string) (FileSystemProvider, error) { + return providers.NewEmbedFSProvider(embedFS, zipFile) +} + +// Policy constructor functions + +// SimpleCache creates a simple cache policy with the given TTL in seconds. +func SimpleCache(seconds int) CachePolicy { + return policies.NewSimpleCachePolicy(seconds) +} + +// 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 ExtensionCache(rules map[string]int, defaultTime int) CachePolicy { + return policies.NewExtensionBasedCachePolicy(rules, defaultTime) +} + +// NoCache creates a cache policy that disables all caching. +func NoCache() CachePolicy { + return policies.NewNoCachePolicy() +} + +// HTMLFallback creates a fallback strategy for SPAs that serves the given index file. +func HTMLFallback(indexFile string) FallbackStrategy { + return policies.NewHTMLFallbackStrategy(indexFile) +} + +// 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 ExtensionFallback(staticExtensions []string, fallbackPath string) FallbackStrategy { + return policies.NewExtensionBasedFallback(staticExtensions, fallbackPath) +} + +// DefaultExtensionFallback creates an extension-based fallback with common web asset extensions. +func DefaultExtensionFallback(fallbackPath string) FallbackStrategy { + return policies.NewDefaultExtensionBasedFallback(fallbackPath) +} + +// DefaultMIMEResolver creates a MIME resolver with common web file types. +func DefaultMIMEResolver() MIMETypeResolver { + return policies.NewDefaultMIMEResolver() +} diff --git a/pkg/server/staticweb/example_reload_test.go b/pkg/server/staticweb/example_reload_test.go new file mode 100644 index 0000000..c57cc8a --- /dev/null +++ b/pkg/server/staticweb/example_reload_test.go @@ -0,0 +1,60 @@ +package staticweb_test + +import ( + "fmt" + + "github.com/bitechdev/ResolveSpec/pkg/server/staticweb" + staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing" +) + +// Example_reload demonstrates reloading content when files change. +func Example_reload() { + 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 demonstrates reloading a zip file provider. +func Example_reloadZip() { + 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 +} diff --git a/pkg/server/staticweb/example_test.go b/pkg/server/staticweb/example_test.go new file mode 100644 index 0000000..f4b4e1a --- /dev/null +++ b/pkg/server/staticweb/example_test.go @@ -0,0 +1,138 @@ +package staticweb_test + +import ( + "fmt" + "net/http" + + "github.com/bitechdev/ResolveSpec/pkg/server/staticweb" + staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing" + "github.com/gorilla/mux" +) + +// Example_basic demonstrates serving files from a local directory. +func Example_basic() { + service := staticweb.NewService(nil) + + // Using mock provider for example purposes + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("test"), + }) + + _ = 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_spa demonstrates an SPA with HTML fallback routing. +func Example_spa() { + service := staticweb.NewService(nil) + + // Using mock provider for example purposes + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("app"), + }) + + _ = 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_multiple demonstrates multiple mount points with different policies. +func Example_multiple() { + 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("test"), + }) + 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_zip demonstrates serving from a zip file (concept). +func Example_zip() { + 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 +} + +// Example_extensionCache demonstrates extension-based caching. +func Example_extensionCache() { + service := staticweb.NewService(nil) + + // Using mock provider for example purposes + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("test"), + "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 +} diff --git a/pkg/server/staticweb/interfaces.go b/pkg/server/staticweb/interfaces.go new file mode 100644 index 0000000..612fc20 --- /dev/null +++ b/pkg/server/staticweb/interfaces.go @@ -0,0 +1,130 @@ +package staticweb + +import ( + "io/fs" + "net/http" +) + +// FileSystemProvider abstracts the source of files (local, zip, embedded, future: http, s3) +// Implementations must be safe for concurrent use. +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 +} + +// 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 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 +} + +// CachePolicy defines how files should be cached by browsers and proxies. +// Implementations must be safe for concurrent use. +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 +} + +// MIMETypeResolver determines the Content-Type for files. +// Implementations must be safe for concurrent use. +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) +} + +// 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. +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 +} + +// MountConfig configures a single mount point. +// A mount point connects a URL prefix to a filesystem provider with optional policies. +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 +} + +// StaticFileService manages multiple mount points and serves static files. +// The service is safe for concurrent use. +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 +} diff --git a/pkg/server/staticweb/mount.go b/pkg/server/staticweb/mount.go new file mode 100644 index 0000000..8a296a6 --- /dev/null +++ b/pkg/server/staticweb/mount.go @@ -0,0 +1,235 @@ +package staticweb + +import ( + "fmt" + "io" + "io/fs" + "net/http" + "path" + "strings" +) + +// mountPoint represents a mounted filesystem at a specific URL prefix. +type mountPoint struct { + urlPrefix string + provider FileSystemProvider + cachePolicy CachePolicy + mimeResolver MIMETypeResolver + fallbackStrategy FallbackStrategy + fileServer http.Handler +} + +// newMountPoint creates a new mount point with the given configuration. +func newMountPoint(config MountConfig, defaults *ServiceConfig) (*mountPoint, error) { + if config.URLPrefix == "" { + return nil, fmt.Errorf("URLPrefix cannot be empty") + } + + if !strings.HasPrefix(config.URLPrefix, "/") { + return nil, fmt.Errorf("URLPrefix must start with /") + } + + if config.Provider == nil { + return nil, fmt.Errorf("Provider cannot be nil") + } + + mp := &mountPoint{ + urlPrefix: config.URLPrefix, + provider: config.Provider, + cachePolicy: config.CachePolicy, + mimeResolver: config.MIMEResolver, + fallbackStrategy: config.FallbackStrategy, + } + + // Apply defaults if policies are not specified + if mp.cachePolicy == nil && defaults != nil { + mp.cachePolicy = defaultCachePolicy(defaults.DefaultCacheTime) + } + + if mp.mimeResolver == nil { + mp.mimeResolver = defaultMIMEResolver() + } + + // Create an http.FileServer for serving files + mp.fileServer = http.FileServer(http.FS(config.Provider)) + + return mp, nil +} + +// ServeHTTP handles HTTP requests for files in this mount point. +func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Strip the URL prefix to get the file path + filePath := strings.TrimPrefix(r.URL.Path, m.urlPrefix) + if filePath == "" { + filePath = "/" + } + + // Clean the path + filePath = path.Clean(filePath) + + // Try to open the file + file, err := m.provider.Open(strings.TrimPrefix(filePath, "/")) + if err != nil { + // File doesn't exist - check if we should use fallback + if m.fallbackStrategy != nil && m.fallbackStrategy.ShouldFallback(filePath) { + fallbackPath := m.fallbackStrategy.GetFallbackPath(filePath) + file, err = m.provider.Open(strings.TrimPrefix(fallbackPath, "/")) + if err == nil { + // Successfully opened fallback file + defer file.Close() + m.serveFile(w, r, fallbackPath, file) + return + } + } + + // No fallback or fallback failed - return 404 + http.NotFound(w, r) + return + } + defer file.Close() + + // Serve the file + m.serveFile(w, r, filePath, file) +} + +// serveFile serves a single file with appropriate headers. +func (m *mountPoint) serveFile(w http.ResponseWriter, r *http.Request, filePath string, file fs.File) { + // Get file info + stat, err := file.Stat() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // If it's a directory, try to serve index.html + if stat.IsDir() { + indexPath := path.Join(filePath, "index.html") + indexFile, err := m.provider.Open(strings.TrimPrefix(indexPath, "/")) + if err != nil { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + defer indexFile.Close() + + indexStat, err := indexFile.Stat() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + filePath = indexPath + stat = indexStat + file = indexFile + } + + // Set Content-Type header using MIME resolver + if m.mimeResolver != nil { + if mimeType := m.mimeResolver.GetMIMEType(filePath); mimeType != "" { + w.Header().Set("Content-Type", mimeType) + } + } + + // Apply cache policy + if m.cachePolicy != nil { + headers := m.cachePolicy.GetCacheHeaders(filePath) + for key, value := range headers { + w.Header().Set(key, value) + } + } + + // Serve the content + if seeker, ok := file.(interface { + io.ReadSeeker + }); ok { + http.ServeContent(w, r, stat.Name(), stat.ModTime(), seeker) + } else { + // If the file doesn't support seeking, we need to read it all into memory + data, err := fs.ReadFile(m.provider, strings.TrimPrefix(filePath, "/")) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + http.ServeContent(w, r, stat.Name(), stat.ModTime(), strings.NewReader(string(data))) + } +} + +// Close releases resources held by the mount point. +func (m *mountPoint) Close() error { + if m.provider != nil { + return m.provider.Close() + } + return nil +} + +// defaultCachePolicy creates a default simple cache policy. +func defaultCachePolicy(cacheTime int) CachePolicy { + // Import the policies package type - we'll need to use the concrete type + // For now, create a simple inline implementation + return &simpleCachePolicy{cacheTime: cacheTime} +} + +// simpleCachePolicy is a simple inline implementation of CachePolicy +type simpleCachePolicy struct { + cacheTime int +} + +func (p *simpleCachePolicy) GetCacheTime(path string) int { + return p.cacheTime +} + +func (p *simpleCachePolicy) GetCacheHeaders(path string) map[string]string { + if p.cacheTime <= 0 { + return map[string]string{ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + } + return map[string]string{ + "Cache-Control": fmt.Sprintf("public, max-age=%d", p.cacheTime), + } +} + +// defaultMIMEResolver creates a default MIME resolver. +func defaultMIMEResolver() MIMETypeResolver { + // Import the policies package type - we'll need to use the concrete type + // For now, create a simple inline implementation + return &simpleMIMEResolver{ + types: map[string]string{ + ".js": "application/javascript", + ".mjs": "application/javascript", + ".cjs": "application/javascript", + ".css": "text/css", + ".html": "text/html", + ".htm": "text/html", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".txt": "text/plain", + }, + } +} + +// simpleMIMEResolver is a simple inline implementation of MIMETypeResolver +type simpleMIMEResolver struct { + types map[string]string +} + +func (r *simpleMIMEResolver) GetMIMEType(filePath string) string { + ext := strings.ToLower(path.Ext(filePath)) + if mimeType, ok := r.types[ext]; ok { + return mimeType + } + return "" +} + +func (r *simpleMIMEResolver) RegisterMIMEType(extension, mimeType string) { + if !strings.HasPrefix(extension, ".") { + extension = "." + extension + } + r.types[strings.ToLower(extension)] = mimeType +} diff --git a/pkg/server/staticweb/plan.md b/pkg/server/staticweb/plan.md new file mode 100644 index 0000000..646466b --- /dev/null +++ b/pkg/server/staticweb/plan.md @@ -0,0 +1,592 @@ +# 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 +1. **Interface-first**: Define clear interfaces for all abstractions +2. **Composition over inheritance**: Combine small, focused components +3. **Router-agnostic**: Return standard `http.Handler` for universal compatibility +4. **Configurable policies**: Extract hardcoded behavior into pluggable strategies +5. **Resource safety**: Proper lifecycle management with `Close()` methods +6. **Testability**: Mock-friendly interfaces with clear boundaries +7. **Extensibility**: Easy to add new providers (HTTP, S3, etc.) in future + +## Architecture Overview + +### Core Interfaces (pkg/server/staticweb/interfaces.go) + +```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) + +```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 files +- `ExtensionBasedCachePolicy` - Different TTL per file extension +- `NoCachePolicy` - Disables caching + +**File**: `policies/mime.go` +- `DefaultMIMEResolver` - Standard web MIME types + stdlib +- `ConfigurableMIMEResolver` - User-defined mappings +- Migrate hardcoded MIME types from `InitMimeTypes()` (lines 60-76) + +**File**: `policies/fallback.go` +- `NoFallback` - Returns 404 for missing files +- `HTMLFallbackStrategy` - 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` +```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` +```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` +```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) + +```go +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 `FallbackStrategy` if 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) + +```go +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) + +```go +// 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.FS` interface correctly + +### Step 8: Create Examples and Tests + +**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/example_test.go` (NEW) + +```go +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 storage +- `MockCachePolicy` - Configurable cache behavior +- `MockMIMEResolver` - 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 + +```go +// 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 + +```go +// 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 + +```go +defer service.Close() // Cleans up all providers +``` + +### 4. Testability +**Before**: Hard to test, coupled to filesystem +**After**: Mock providers for testing + +```go +mockProvider := testing.NewInMemoryProvider(map[string][]byte{ + "index.html": []byte("test"), +}) + +service.Mount(MountConfig{ + URLPrefix: "/", + Provider: mockProvider, +}) +``` + +### 5. Extensibility +**Before**: Need to modify code to support new file sources +**After**: Implement `FileSystemProvider` interface + +```go +// 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: + +```go +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: + +```go +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): +1. `pkg/server/staticweb/interfaces.go` - Core contracts +2. `pkg/server/staticweb/config.go` - Configuration structs and helpers +3. `pkg/server/staticweb/policies/cache.go` - Cache policy implementations +4. `pkg/server/staticweb/policies/mime.go` - MIME resolver implementations +5. `pkg/server/staticweb/policies/fallback.go` - Fallback strategy implementations +6. `pkg/server/staticweb/providers/local.go` - Local directory provider +7. `pkg/server/staticweb/providers/zip.go` - Zip file provider +8. `pkg/server/staticweb/providers/embed.go` - Embedded filesystem provider +9. `pkg/server/staticweb/mount.go` - Mount point implementation +10. `pkg/server/staticweb/service.go` - Main service implementation +11. `pkg/server/staticweb/testing/mocks.go` - Test helpers +12. `pkg/server/staticweb/service_test.go` - Service tests +13. `pkg/server/staticweb/providers/providers_test.go` - Provider tests +14. `pkg/server/staticweb/example_test.go` - Example code +15. `pkg/server/staticweb/README.md` - Documentation + +### Files to REMOVE: +1. `pkg/server/staticweb/staticweb.go` - Replaced by new design + +### Files to KEEP: +1. `pkg/server/zipfs/zipfs.go` - Used by ZipFSProvider + +### Files for FUTURE (not in this refactoring): +1. `pkg/server/staticweb/providers/http.go` - HTTP/HTTPS remote provider +2. `pkg/server/staticweb/providers/s3.go` - S3-compatible storage provider +3. `pkg/server/staticweb/providers/composite.go` - Fallback chain provider + +## Example Usage After Refactoring + +### Basic Static Site +```go +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 +```go +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 +```go +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 +//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) +```go +// 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 +1. Interfaces (foundation) +2. Configuration (API surface) +3. Policies (pluggable behavior) +4. Providers (filesystem abstraction) +5. Mount Point (request handling) +6. Service (orchestration) +7. Tests (validation) +8. Documentation (usage) +9. Remove old code (cleanup) + +This order ensures each layer builds on tested, working components. + +--- + diff --git a/pkg/server/staticweb/policies/cache.go b/pkg/server/staticweb/policies/cache.go new file mode 100644 index 0000000..5e19e5a --- /dev/null +++ b/pkg/server/staticweb/policies/cache.go @@ -0,0 +1,103 @@ +package policies + +import ( + "fmt" + "path" + "strings" +) + +// SimpleCachePolicy implements a basic cache policy with a single TTL for all files. +type SimpleCachePolicy struct { + cacheTime int // Cache duration in seconds +} + +// NewSimpleCachePolicy creates a new SimpleCachePolicy with the given cache time in seconds. +func NewSimpleCachePolicy(cacheTimeSeconds int) *SimpleCachePolicy { + return &SimpleCachePolicy{ + cacheTime: cacheTimeSeconds, + } +} + +// GetCacheTime returns the cache duration for any file. +func (p *SimpleCachePolicy) GetCacheTime(filePath string) int { + return p.cacheTime +} + +// GetCacheHeaders returns the Cache-Control header for the given file. +func (p *SimpleCachePolicy) GetCacheHeaders(filePath string) map[string]string { + if p.cacheTime <= 0 { + return map[string]string{ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + } + + return map[string]string{ + "Cache-Control": fmt.Sprintf("public, max-age=%d", p.cacheTime), + } +} + +// ExtensionBasedCachePolicy implements a cache policy that varies by file extension. +type ExtensionBasedCachePolicy struct { + rules map[string]int // Extension -> cache time in seconds + defaultTime int // Default cache time for unmatched extensions +} + +// NewExtensionBasedCachePolicy creates a new ExtensionBasedCachePolicy. +// rules maps file extensions (with leading dot, e.g., ".js") to cache times in seconds. +// defaultTime is used for files that don't match any rule. +func NewExtensionBasedCachePolicy(rules map[string]int, defaultTime int) *ExtensionBasedCachePolicy { + return &ExtensionBasedCachePolicy{ + rules: rules, + defaultTime: defaultTime, + } +} + +// GetCacheTime returns the cache duration based on the file extension. +func (p *ExtensionBasedCachePolicy) GetCacheTime(filePath string) int { + ext := strings.ToLower(path.Ext(filePath)) + if cacheTime, ok := p.rules[ext]; ok { + return cacheTime + } + return p.defaultTime +} + +// GetCacheHeaders returns cache headers based on the file extension. +func (p *ExtensionBasedCachePolicy) GetCacheHeaders(filePath string) map[string]string { + cacheTime := p.GetCacheTime(filePath) + + if cacheTime <= 0 { + return map[string]string{ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + } + + return map[string]string{ + "Cache-Control": fmt.Sprintf("public, max-age=%d", cacheTime), + } +} + +// NoCachePolicy implements a cache policy that disables all caching. +type NoCachePolicy struct{} + +// NewNoCachePolicy creates a new NoCachePolicy. +func NewNoCachePolicy() *NoCachePolicy { + return &NoCachePolicy{} +} + +// GetCacheTime always returns 0 (no caching). +func (p *NoCachePolicy) GetCacheTime(filePath string) int { + return 0 +} + +// GetCacheHeaders returns headers that disable caching. +func (p *NoCachePolicy) GetCacheHeaders(filePath string) map[string]string { + return map[string]string{ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } +} diff --git a/pkg/server/staticweb/policies/fallback.go b/pkg/server/staticweb/policies/fallback.go new file mode 100644 index 0000000..3bdc8b2 --- /dev/null +++ b/pkg/server/staticweb/policies/fallback.go @@ -0,0 +1,159 @@ +package policies + +import ( + "path" + "strings" +) + +// NoFallback implements a fallback strategy that never falls back. +// All requests for missing files will result in 404 responses. +type NoFallback struct{} + +// NewNoFallback creates a new NoFallback strategy. +func NewNoFallback() *NoFallback { + return &NoFallback{} +} + +// ShouldFallback always returns false. +func (f *NoFallback) ShouldFallback(filePath string) bool { + return false +} + +// GetFallbackPath returns an empty string (never called since ShouldFallback returns false). +func (f *NoFallback) GetFallbackPath(filePath string) string { + return "" +} + +// HTMLFallbackStrategy implements a fallback strategy for Single Page Applications (SPAs). +// It serves a specified HTML file (typically index.html) for non-file requests. +type HTMLFallbackStrategy struct { + indexFile string +} + +// NewHTMLFallbackStrategy creates a new HTMLFallbackStrategy. +// indexFile is the path to the HTML file to serve (e.g., "index.html", "/index.html"). +func NewHTMLFallbackStrategy(indexFile string) *HTMLFallbackStrategy { + return &HTMLFallbackStrategy{ + indexFile: indexFile, + } +} + +// ShouldFallback returns true for requests that don't look like static assets. +func (f *HTMLFallbackStrategy) ShouldFallback(filePath string) bool { + // Always fall back unless it looks like a static asset + return !f.isStaticAsset(filePath) +} + +// GetFallbackPath returns the index file path. +func (f *HTMLFallbackStrategy) GetFallbackPath(filePath string) string { + return f.indexFile +} + +// isStaticAsset checks if the path looks like a static asset (has a file extension). +func (f *HTMLFallbackStrategy) isStaticAsset(filePath string) bool { + return path.Ext(filePath) != "" +} + +// ExtensionBasedFallback implements a fallback strategy that skips fallback for known static file extensions. +// This is the behavior from the original StaticHTMLFallbackHandler. +type ExtensionBasedFallback struct { + staticExtensions map[string]bool + fallbackPath string +} + +// NewExtensionBasedFallback creates a new ExtensionBasedFallback strategy. +// staticExtensions is a list of file extensions (with leading dot) that should NOT use fallback. +// fallbackPath is the file to serve when fallback is triggered. +func NewExtensionBasedFallback(staticExtensions []string, fallbackPath string) *ExtensionBasedFallback { + extMap := make(map[string]bool) + for _, ext := range staticExtensions { + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + extMap[strings.ToLower(ext)] = true + } + + return &ExtensionBasedFallback{ + staticExtensions: extMap, + fallbackPath: fallbackPath, + } +} + +// NewDefaultExtensionBasedFallback creates an ExtensionBasedFallback with common web asset extensions. +// This matches the behavior of the original StaticHTMLFallbackHandler. +func NewDefaultExtensionBasedFallback(fallbackPath string) *ExtensionBasedFallback { + return NewExtensionBasedFallback([]string{ + ".js", ".css", ".png", ".svg", ".ico", ".json", + ".jpg", ".jpeg", ".gif", ".woff", ".woff2", ".ttf", ".eot", + }, fallbackPath) +} + +// ShouldFallback returns true if the file path doesn't have a static asset extension. +func (f *ExtensionBasedFallback) ShouldFallback(filePath string) bool { + ext := strings.ToLower(path.Ext(filePath)) + + // If it's a known static extension, don't fallback + if f.staticExtensions[ext] { + return false + } + + // Otherwise, try fallback + return true +} + +// GetFallbackPath returns the configured fallback path. +func (f *ExtensionBasedFallback) GetFallbackPath(filePath string) string { + return f.fallbackPath +} + +// HTMLExtensionFallback implements a fallback strategy that appends .html to paths. +// This tries to serve {path}.html for missing files. +type HTMLExtensionFallback struct { + staticExtensions map[string]bool +} + +// NewHTMLExtensionFallback creates a new HTMLExtensionFallback strategy. +func NewHTMLExtensionFallback(staticExtensions []string) *HTMLExtensionFallback { + extMap := make(map[string]bool) + for _, ext := range staticExtensions { + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + extMap[strings.ToLower(ext)] = true + } + + return &HTMLExtensionFallback{ + staticExtensions: extMap, + } +} + +// ShouldFallback returns true if the path doesn't have a static extension or .html. +func (f *HTMLExtensionFallback) ShouldFallback(filePath string) bool { + ext := strings.ToLower(path.Ext(filePath)) + + // If it's a known static extension, don't fallback + if f.staticExtensions[ext] { + return false + } + + // If it already has .html, don't fallback + if ext == ".html" || ext == ".htm" { + return false + } + + return true +} + +// GetFallbackPath returns the path with .html appended. +func (f *HTMLExtensionFallback) GetFallbackPath(filePath string) string { + cleanPath := path.Clean(filePath) + if !strings.HasSuffix(filePath, "/") { + cleanPath = strings.TrimRight(cleanPath, "/") + } + + if !strings.HasSuffix(strings.ToLower(cleanPath), ".html") { + return cleanPath + ".html" + } + + return cleanPath +} diff --git a/pkg/server/staticweb/policies/mime.go b/pkg/server/staticweb/policies/mime.go new file mode 100644 index 0000000..71e82d1 --- /dev/null +++ b/pkg/server/staticweb/policies/mime.go @@ -0,0 +1,245 @@ +package policies + +import ( + "mime" + "path" + "strings" + "sync" +) + +// DefaultMIMEResolver implements a MIME type resolver using Go's standard mime package +// and a set of common web file type mappings. +type DefaultMIMEResolver struct { + customTypes map[string]string + mu sync.RWMutex +} + +// NewDefaultMIMEResolver creates a new DefaultMIMEResolver with common web MIME types. +func NewDefaultMIMEResolver() *DefaultMIMEResolver { + resolver := &DefaultMIMEResolver{ + customTypes: make(map[string]string), + } + + // JavaScript & TypeScript + resolver.RegisterMIMEType(".js", "application/javascript") + resolver.RegisterMIMEType(".mjs", "application/javascript") + resolver.RegisterMIMEType(".cjs", "application/javascript") + resolver.RegisterMIMEType(".ts", "text/typescript") + resolver.RegisterMIMEType(".tsx", "text/tsx") + resolver.RegisterMIMEType(".jsx", "text/jsx") + + // CSS & Styling + resolver.RegisterMIMEType(".css", "text/css") + resolver.RegisterMIMEType(".scss", "text/x-scss") + resolver.RegisterMIMEType(".sass", "text/x-sass") + resolver.RegisterMIMEType(".less", "text/x-less") + + // HTML & XML + resolver.RegisterMIMEType(".html", "text/html") + resolver.RegisterMIMEType(".htm", "text/html") + resolver.RegisterMIMEType(".xml", "application/xml") + resolver.RegisterMIMEType(".xhtml", "application/xhtml+xml") + + // Images - Raster + resolver.RegisterMIMEType(".png", "image/png") + resolver.RegisterMIMEType(".jpg", "image/jpeg") + resolver.RegisterMIMEType(".jpeg", "image/jpeg") + resolver.RegisterMIMEType(".gif", "image/gif") + resolver.RegisterMIMEType(".webp", "image/webp") + resolver.RegisterMIMEType(".avif", "image/avif") + resolver.RegisterMIMEType(".bmp", "image/bmp") + resolver.RegisterMIMEType(".tiff", "image/tiff") + resolver.RegisterMIMEType(".tif", "image/tiff") + resolver.RegisterMIMEType(".ico", "image/x-icon") + resolver.RegisterMIMEType(".cur", "image/x-icon") + + // Images - Vector + resolver.RegisterMIMEType(".svg", "image/svg+xml") + resolver.RegisterMIMEType(".svgz", "image/svg+xml") + + // Fonts + resolver.RegisterMIMEType(".woff", "font/woff") + resolver.RegisterMIMEType(".woff2", "font/woff2") + resolver.RegisterMIMEType(".ttf", "font/ttf") + resolver.RegisterMIMEType(".otf", "font/otf") + resolver.RegisterMIMEType(".eot", "application/vnd.ms-fontobject") + + // Audio + resolver.RegisterMIMEType(".mp3", "audio/mpeg") + resolver.RegisterMIMEType(".wav", "audio/wav") + resolver.RegisterMIMEType(".ogg", "audio/ogg") + resolver.RegisterMIMEType(".oga", "audio/ogg") + resolver.RegisterMIMEType(".m4a", "audio/mp4") + resolver.RegisterMIMEType(".aac", "audio/aac") + resolver.RegisterMIMEType(".flac", "audio/flac") + resolver.RegisterMIMEType(".opus", "audio/opus") + resolver.RegisterMIMEType(".weba", "audio/webm") + + // Video + resolver.RegisterMIMEType(".mp4", "video/mp4") + resolver.RegisterMIMEType(".webm", "video/webm") + resolver.RegisterMIMEType(".ogv", "video/ogg") + resolver.RegisterMIMEType(".avi", "video/x-msvideo") + resolver.RegisterMIMEType(".mpeg", "video/mpeg") + resolver.RegisterMIMEType(".mpg", "video/mpeg") + resolver.RegisterMIMEType(".mov", "video/quicktime") + resolver.RegisterMIMEType(".wmv", "video/x-ms-wmv") + resolver.RegisterMIMEType(".flv", "video/x-flv") + resolver.RegisterMIMEType(".mkv", "video/x-matroska") + resolver.RegisterMIMEType(".m4v", "video/mp4") + + // Data & Configuration + resolver.RegisterMIMEType(".json", "application/json") + resolver.RegisterMIMEType(".xml", "application/xml") + resolver.RegisterMIMEType(".yml", "application/yaml") + resolver.RegisterMIMEType(".yaml", "application/yaml") + resolver.RegisterMIMEType(".toml", "application/toml") + resolver.RegisterMIMEType(".ini", "text/plain") + resolver.RegisterMIMEType(".conf", "text/plain") + resolver.RegisterMIMEType(".config", "text/plain") + + // Documents + resolver.RegisterMIMEType(".pdf", "application/pdf") + resolver.RegisterMIMEType(".doc", "application/msword") + resolver.RegisterMIMEType(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + resolver.RegisterMIMEType(".xls", "application/vnd.ms-excel") + resolver.RegisterMIMEType(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + resolver.RegisterMIMEType(".ppt", "application/vnd.ms-powerpoint") + resolver.RegisterMIMEType(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation") + resolver.RegisterMIMEType(".odt", "application/vnd.oasis.opendocument.text") + resolver.RegisterMIMEType(".ods", "application/vnd.oasis.opendocument.spreadsheet") + resolver.RegisterMIMEType(".odp", "application/vnd.oasis.opendocument.presentation") + + // Archives + resolver.RegisterMIMEType(".zip", "application/zip") + resolver.RegisterMIMEType(".tar", "application/x-tar") + resolver.RegisterMIMEType(".gz", "application/gzip") + resolver.RegisterMIMEType(".bz2", "application/x-bzip2") + resolver.RegisterMIMEType(".7z", "application/x-7z-compressed") + resolver.RegisterMIMEType(".rar", "application/vnd.rar") + + // Text files + resolver.RegisterMIMEType(".txt", "text/plain") + resolver.RegisterMIMEType(".md", "text/markdown") + resolver.RegisterMIMEType(".markdown", "text/markdown") + resolver.RegisterMIMEType(".csv", "text/csv") + resolver.RegisterMIMEType(".log", "text/plain") + + // Source code (for syntax highlighting in browsers) + resolver.RegisterMIMEType(".c", "text/x-c") + resolver.RegisterMIMEType(".cpp", "text/x-c++") + resolver.RegisterMIMEType(".h", "text/x-c") + resolver.RegisterMIMEType(".hpp", "text/x-c++") + resolver.RegisterMIMEType(".go", "text/x-go") + resolver.RegisterMIMEType(".py", "text/x-python") + resolver.RegisterMIMEType(".java", "text/x-java") + resolver.RegisterMIMEType(".rs", "text/x-rust") + resolver.RegisterMIMEType(".rb", "text/x-ruby") + resolver.RegisterMIMEType(".php", "text/x-php") + resolver.RegisterMIMEType(".sh", "text/x-shellscript") + resolver.RegisterMIMEType(".bash", "text/x-shellscript") + resolver.RegisterMIMEType(".sql", "text/x-sql") + resolver.RegisterMIMEType(".template.sql", "text/plain") + resolver.RegisterMIMEType(".upg", "text/plain") + + // Web Assembly + resolver.RegisterMIMEType(".wasm", "application/wasm") + + // Manifest & Service Worker + resolver.RegisterMIMEType(".webmanifest", "application/manifest+json") + resolver.RegisterMIMEType(".manifest", "text/cache-manifest") + + // 3D Models + resolver.RegisterMIMEType(".gltf", "model/gltf+json") + resolver.RegisterMIMEType(".glb", "model/gltf-binary") + resolver.RegisterMIMEType(".obj", "model/obj") + resolver.RegisterMIMEType(".stl", "model/stl") + + // Other common web assets + resolver.RegisterMIMEType(".map", "application/json") // Source maps + resolver.RegisterMIMEType(".swf", "application/x-shockwave-flash") + resolver.RegisterMIMEType(".apk", "application/vnd.android.package-archive") + resolver.RegisterMIMEType(".dmg", "application/x-apple-diskimage") + resolver.RegisterMIMEType(".exe", "application/x-msdownload") + resolver.RegisterMIMEType(".iso", "application/x-iso9660-image") + + return resolver +} + +// GetMIMEType returns the MIME type for the given file path. +// It first checks custom registered types, then falls back to Go's mime.TypeByExtension. +func (r *DefaultMIMEResolver) GetMIMEType(filePath string) string { + ext := strings.ToLower(path.Ext(filePath)) + + // Check custom types first + r.mu.RLock() + if mimeType, ok := r.customTypes[ext]; ok { + r.mu.RUnlock() + return mimeType + } + r.mu.RUnlock() + + // Fall back to standard library + if mimeType := mime.TypeByExtension(ext); mimeType != "" { + return mimeType + } + + // Return empty string if unknown + return "" +} + +// RegisterMIMEType registers a custom MIME type for the given file extension. +func (r *DefaultMIMEResolver) RegisterMIMEType(extension, mimeType string) { + if !strings.HasPrefix(extension, ".") { + extension = "." + extension + } + + r.mu.Lock() + r.customTypes[strings.ToLower(extension)] = mimeType + r.mu.Unlock() +} + +// ConfigurableMIMEResolver implements a MIME type resolver with user-defined mappings only. +// It does not use any default mappings. +type ConfigurableMIMEResolver struct { + types map[string]string + mu sync.RWMutex +} + +// NewConfigurableMIMEResolver creates a new ConfigurableMIMEResolver with the given mappings. +func NewConfigurableMIMEResolver(types map[string]string) *ConfigurableMIMEResolver { + resolver := &ConfigurableMIMEResolver{ + types: make(map[string]string), + } + + for ext, mimeType := range types { + resolver.RegisterMIMEType(ext, mimeType) + } + + return resolver +} + +// GetMIMEType returns the MIME type for the given file path. +func (r *ConfigurableMIMEResolver) GetMIMEType(filePath string) string { + ext := strings.ToLower(path.Ext(filePath)) + + r.mu.RLock() + defer r.mu.RUnlock() + + if mimeType, ok := r.types[ext]; ok { + return mimeType + } + + return "" +} + +// RegisterMIMEType registers a MIME type for the given file extension. +func (r *ConfigurableMIMEResolver) RegisterMIMEType(extension, mimeType string) { + if !strings.HasPrefix(extension, ".") { + extension = "." + extension + } + + r.mu.Lock() + r.types[strings.ToLower(extension)] = mimeType + r.mu.Unlock() +} diff --git a/pkg/server/staticweb/providers/embed.go b/pkg/server/staticweb/providers/embed.go new file mode 100644 index 0000000..c8ebd9d --- /dev/null +++ b/pkg/server/staticweb/providers/embed.go @@ -0,0 +1,119 @@ +package providers + +import ( + "archive/zip" + "bytes" + "embed" + "fmt" + "io" + "io/fs" + "sync" + + "github.com/bitechdev/ResolveSpec/pkg/server/zipfs" +) + +// EmbedFSProvider serves files from an embedded filesystem. +// It supports both direct embedded directories and embedded zip files. +type EmbedFSProvider struct { + embedFS *embed.FS + zipFile string // Optional: path within embedded FS to zip file + zipReader *zip.Reader + fs fs.FS + mu sync.RWMutex +} + +// NewEmbedFSProvider creates a new EmbedFSProvider. +// 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. +func NewEmbedFSProvider(embedFS fs.FS, zipFile string) (*EmbedFSProvider, error) { + if embedFS == nil { + return nil, fmt.Errorf("embedded filesystem cannot be nil") + } + + // Try to cast to *embed.FS for tracking purposes + var embedFSPtr *embed.FS + if efs, ok := embedFS.(*embed.FS); ok { + embedFSPtr = efs + } + + provider := &EmbedFSProvider{ + embedFS: embedFSPtr, + zipFile: zipFile, + } + + // If zipFile is specified, open it as a zip archive + if zipFile != "" { + // Read the zip file from the embedded FS + // We need to check if the FS supports ReadFile + var data []byte + var err error + + if readFileFS, ok := embedFS.(interface{ ReadFile(string) ([]byte, error) }); ok { + data, err = readFileFS.ReadFile(zipFile) + } else { + // Fall back to Open and reading + file, openErr := embedFS.Open(zipFile) + if openErr != nil { + return nil, fmt.Errorf("failed to open embedded zip file: %w", openErr) + } + defer file.Close() + data, err = io.ReadAll(file) + } + if err != nil { + return nil, fmt.Errorf("failed to read embedded zip file: %w", err) + } + + // Create a zip reader from the data + reader := bytes.NewReader(data) + zipReader, err := zip.NewReader(reader, int64(len(data))) + if err != nil { + return nil, fmt.Errorf("failed to create zip reader: %w", err) + } + + provider.zipReader = zipReader + provider.fs = zipfs.NewZipFS(zipReader) + } else { + // Use the embedded FS directly + provider.fs = embedFS + } + + return provider, nil +} + +// Open opens the named file from the embedded filesystem. +func (p *EmbedFSProvider) Open(name string) (fs.File, error) { + p.mu.RLock() + defer p.mu.RUnlock() + + if p.fs == nil { + return nil, fmt.Errorf("embedded filesystem is closed") + } + + return p.fs.Open(name) +} + +// Close releases any resources held by the provider. +// For embedded filesystems, this is mostly a no-op since Go manages the lifecycle. +func (p *EmbedFSProvider) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + + // Clear references to allow garbage collection + p.fs = nil + p.zipReader = nil + + return nil +} + +// Type returns "embed" or "embed-zip" depending on the configuration. +func (p *EmbedFSProvider) Type() string { + if p.zipFile != "" { + return "embed-zip" + } + return "embed" +} + +// ZipFile returns the path to the zip file within the embedded FS, if any. +func (p *EmbedFSProvider) ZipFile() string { + return p.zipFile +} diff --git a/pkg/server/staticweb/providers/local.go b/pkg/server/staticweb/providers/local.go new file mode 100644 index 0000000..cdb05a8 --- /dev/null +++ b/pkg/server/staticweb/providers/local.go @@ -0,0 +1,80 @@ +package providers + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// LocalFSProvider serves files from a local directory. +type LocalFSProvider struct { + path string + fs fs.FS +} + +// NewLocalFSProvider creates a new LocalFSProvider for the given directory path. +// The path must be an absolute path to an existing directory. +func NewLocalFSProvider(path string) (*LocalFSProvider, error) { + // Validate that the path exists and is a directory + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("failed to stat directory: %w", err) + } + + if !info.IsDir() { + return nil, fmt.Errorf("path is not a directory: %s", path) + } + + // Convert to absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + return &LocalFSProvider{ + path: absPath, + fs: os.DirFS(absPath), + }, nil +} + +// Open opens the named file from the local directory. +func (p *LocalFSProvider) Open(name string) (fs.File, error) { + return p.fs.Open(name) +} + +// Close releases any resources held by the provider. +// For local filesystem, this is a no-op since os.DirFS doesn't hold resources. +func (p *LocalFSProvider) Close() error { + return nil +} + +// Type returns "local". +func (p *LocalFSProvider) Type() string { + return "local" +} + +// Path returns the absolute path to the directory being served. +func (p *LocalFSProvider) Path() string { + return p.path +} + +// Reload refreshes the filesystem view. +// For local directories, os.DirFS automatically picks up changes, +// so this recreates the DirFS to ensure a fresh view. +func (p *LocalFSProvider) Reload() error { + // Verify the directory still exists + info, err := os.Stat(p.path) + if err != nil { + return fmt.Errorf("failed to stat directory: %w", err) + } + + if !info.IsDir() { + return fmt.Errorf("path is no longer a directory: %s", p.path) + } + + // Recreate the DirFS + p.fs = os.DirFS(p.path) + + return nil +} diff --git a/pkg/server/staticweb/providers/providers_test.go b/pkg/server/staticweb/providers/providers_test.go new file mode 100644 index 0000000..414ba36 --- /dev/null +++ b/pkg/server/staticweb/providers/providers_test.go @@ -0,0 +1,393 @@ +package providers + +import ( + "archive/zip" + "bytes" + "io" + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +func TestLocalFSProvider(t *testing.T) { + // Create a temporary directory with test files + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil { + t.Fatal(err) + } + + provider, err := NewLocalFSProvider(tmpDir) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + defer provider.Close() + + // Test opening a file + file, err := provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + defer file.Close() + + // Read the file + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(data) != "test content" { + t.Errorf("Expected 'test content', got %q", string(data)) + } + + // Test type + if provider.Type() != "local" { + t.Errorf("Expected type 'local', got %q", provider.Type()) + } +} + +func TestZipFSProvider(t *testing.T) { + // Create a temporary zip file + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "test.zip") + + // Create zip file with test content + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + + zipWriter := zip.NewWriter(zipFile) + fileWriter, err := zipWriter.Create("test.txt") + if err != nil { + t.Fatal(err) + } + + _, err = fileWriter.Write([]byte("zip content")) + if err != nil { + t.Fatal(err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatal(err) + } + + if err := zipFile.Close(); err != nil { + t.Fatal(err) + } + + // Test the provider + provider, err := NewZipFSProvider(zipPath) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + defer provider.Close() + + // Test opening a file + file, err := provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + defer file.Close() + + // Read the file + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(data) != "zip content" { + t.Errorf("Expected 'zip content', got %q", string(data)) + } + + // Test type + if provider.Type() != "zip" { + t.Errorf("Expected type 'zip', got %q", provider.Type()) + } +} + +func TestZipFSProviderReload(t *testing.T) { + // Create a temporary zip file + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "test.zip") + + // Helper to create zip with content + createZip := func(content string) { + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + fileWriter, err := zipWriter.Create("test.txt") + if err != nil { + t.Fatal(err) + } + + _, err = fileWriter.Write([]byte(content)) + if err != nil { + t.Fatal(err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatal(err) + } + } + + // Create initial zip + createZip("original content") + + // Test the provider + provider, err := NewZipFSProvider(zipPath) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + defer provider.Close() + + // Read initial content + file, err := provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + data, _ := io.ReadAll(file) + file.Close() + + if string(data) != "original content" { + t.Errorf("Expected 'original content', got %q", string(data)) + } + + // Update the zip file + createZip("updated content") + + // Reload the provider + if err := provider.Reload(); err != nil { + t.Fatalf("Failed to reload: %v", err) + } + + // Read updated content + file, err = provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file after reload: %v", err) + } + data, _ = io.ReadAll(file) + file.Close() + + if string(data) != "updated content" { + t.Errorf("Expected 'updated content', got %q", string(data)) + } +} + +func TestLocalFSProviderReload(t *testing.T) { + // Create a temporary directory with test files + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("original"), 0644); err != nil { + t.Fatal(err) + } + + provider, err := NewLocalFSProvider(tmpDir) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + defer provider.Close() + + // Read initial content + file, err := provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + data, _ := io.ReadAll(file) + file.Close() + + if string(data) != "original" { + t.Errorf("Expected 'original', got %q", string(data)) + } + + // Update the file + if err := os.WriteFile(testFile, []byte("updated"), 0644); err != nil { + t.Fatal(err) + } + + // Reload the provider + if err := provider.Reload(); err != nil { + t.Fatalf("Failed to reload: %v", err) + } + + // Read updated content + file, err = provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file after reload: %v", err) + } + data, _ = io.ReadAll(file) + file.Close() + + if string(data) != "updated" { + t.Errorf("Expected 'updated', got %q", string(data)) + } +} + +func TestEmbedFSProvider(t *testing.T) { + // Test with a mock embed.FS + mockFS := &mockEmbedFS{ + files: map[string][]byte{ + "test.txt": []byte("test content"), + }, + } + + provider, err := NewEmbedFSProvider(mockFS, "") + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + defer provider.Close() + + // Test type + if provider.Type() != "embed" { + t.Errorf("Expected type 'embed', got %q", provider.Type()) + } + + // Test opening a file + file, err := provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + defer file.Close() + + // Read the file + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(data) != "test content" { + t.Errorf("Expected 'test content', got %q", string(data)) + } +} + +func TestEmbedFSProviderWithZip(t *testing.T) { + // Create an embedded-like FS with a zip file + // For simplicity, we'll use a mock embed.FS + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "test.zip") + + // Create zip file + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + + zipWriter := zip.NewWriter(zipFile) + fileWriter, err := zipWriter.Create("test.txt") + if err != nil { + t.Fatal(err) + } + + _, err = fileWriter.Write([]byte("embedded zip content")) + if err != nil { + t.Fatal(err) + } + + zipWriter.Close() + zipFile.Close() + + // Read the zip file + zipData, err := os.ReadFile(zipPath) + if err != nil { + t.Fatal(err) + } + + // Create a mock embed.FS + mockFS := &mockEmbedFS{ + files: map[string][]byte{ + "test.zip": zipData, + }, + } + + provider, err := NewEmbedFSProvider(mockFS, "test.zip") + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + defer provider.Close() + + // Test opening a file + file, err := provider.Open("test.txt") + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + defer file.Close() + + // Read the file + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(data) != "embedded zip content" { + t.Errorf("Expected 'embedded zip content', got %q", string(data)) + } + + // Test type + if provider.Type() != "embed-zip" { + t.Errorf("Expected type 'embed-zip', got %q", provider.Type()) + } +} + +// mockEmbedFS is a mock embed.FS for testing +type mockEmbedFS struct { + files map[string][]byte +} + +func (m *mockEmbedFS) Open(name string) (fs.File, error) { + data, ok := m.files[name] + if !ok { + return nil, os.ErrNotExist + } + + return &mockFile{ + name: name, + reader: bytes.NewReader(data), + size: int64(len(data)), + }, nil +} + +func (m *mockEmbedFS) ReadFile(name string) ([]byte, error) { + data, ok := m.files[name] + if !ok { + return nil, os.ErrNotExist + } + return data, nil +} + +type mockFile struct { + name string + reader *bytes.Reader + size int64 +} + +func (f *mockFile) Stat() (fs.FileInfo, error) { + return &mockFileInfo{name: f.name, size: f.size}, nil +} + +func (f *mockFile) Read(p []byte) (int, error) { + return f.reader.Read(p) +} + +func (f *mockFile) Close() error { + return nil +} + +type mockFileInfo struct { + name string + size int64 +} + +func (fi *mockFileInfo) Name() string { return fi.name } +func (fi *mockFileInfo) Size() int64 { return fi.size } +func (fi *mockFileInfo) Mode() fs.FileMode { return 0644 } +func (fi *mockFileInfo) ModTime() time.Time { return time.Now() } +func (fi *mockFileInfo) IsDir() bool { return false } +func (fi *mockFileInfo) Sys() interface{} { return nil } diff --git a/pkg/server/staticweb/providers/zip.go b/pkg/server/staticweb/providers/zip.go new file mode 100644 index 0000000..cf9de84 --- /dev/null +++ b/pkg/server/staticweb/providers/zip.go @@ -0,0 +1,102 @@ +package providers + +import ( + "archive/zip" + "fmt" + "io/fs" + "path/filepath" + "sync" + + "github.com/bitechdev/ResolveSpec/pkg/server/zipfs" +) + +// ZipFSProvider serves files from a zip file. +type ZipFSProvider struct { + zipPath string + zipReader *zip.ReadCloser + zipFS *zipfs.ZipFS + mu sync.RWMutex +} + +// NewZipFSProvider creates a new ZipFSProvider for the given zip file path. +func NewZipFSProvider(zipPath string) (*ZipFSProvider, error) { + // Convert to absolute path + absPath, err := filepath.Abs(zipPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Open the zip file + zipReader, err := zip.OpenReader(absPath) + if err != nil { + return nil, fmt.Errorf("failed to open zip file: %w", err) + } + + return &ZipFSProvider{ + zipPath: absPath, + zipReader: zipReader, + zipFS: zipfs.NewZipFS(&zipReader.Reader), + }, nil +} + +// Open opens the named file from the zip archive. +func (p *ZipFSProvider) Open(name string) (fs.File, error) { + p.mu.RLock() + defer p.mu.RUnlock() + + if p.zipFS == nil { + return nil, fmt.Errorf("zip filesystem is closed") + } + + return p.zipFS.Open(name) +} + +// Close releases resources held by the zip reader. +func (p *ZipFSProvider) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.zipReader != nil { + err := p.zipReader.Close() + p.zipReader = nil + p.zipFS = nil + return err + } + + return nil +} + +// Type returns "zip". +func (p *ZipFSProvider) Type() string { + return "zip" +} + +// Path returns the absolute path to the zip file being served. +func (p *ZipFSProvider) Path() string { + return p.zipPath +} + +// Reload reopens the zip file to pick up any changes. +// This is useful in development when the zip file is updated. +func (p *ZipFSProvider) Reload() error { + p.mu.Lock() + defer p.mu.Unlock() + + // Close the existing zip reader if open + if p.zipReader != nil { + if err := p.zipReader.Close(); err != nil { + return fmt.Errorf("failed to close old zip reader: %w", err) + } + } + + // Reopen the zip file + zipReader, err := zip.OpenReader(p.zipPath) + if err != nil { + return fmt.Errorf("failed to reopen zip file: %w", err) + } + + p.zipReader = zipReader + p.zipFS = zipfs.NewZipFS(&zipReader.Reader) + + return nil +} diff --git a/pkg/server/staticweb/service.go b/pkg/server/staticweb/service.go new file mode 100644 index 0000000..177018d --- /dev/null +++ b/pkg/server/staticweb/service.go @@ -0,0 +1,189 @@ +package staticweb + +import ( + "fmt" + "net/http" + "sort" + "strings" + "sync" +) + +// service implements the StaticFileService interface. +type service struct { + mounts map[string]*mountPoint // urlPrefix -> mount point + config *ServiceConfig + mu sync.RWMutex +} + +// NewService creates a new static file service with the given configuration. +// If config is nil, default configuration is used. +func NewService(config *ServiceConfig) StaticFileService { + if config == nil { + config = DefaultServiceConfig() + } + + return &service{ + mounts: make(map[string]*mountPoint), + config: config, + } +} + +// Mount adds a new mount point with the given configuration. +func (s *service) Mount(config MountConfig) error { + // Validate the config + if err := s.validateMountConfig(config); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Check if the prefix is already mounted + if _, exists := s.mounts[config.URLPrefix]; exists { + return fmt.Errorf("mount point already exists at %s", config.URLPrefix) + } + + // Create the mount point + mp, err := newMountPoint(config, s.config) + if err != nil { + return fmt.Errorf("failed to create mount point: %w", err) + } + + // Add to the map + s.mounts[config.URLPrefix] = mp + + return nil +} + +// Unmount removes the mount point at the given URL prefix. +func (s *service) Unmount(urlPrefix string) error { + s.mu.Lock() + defer s.mu.Unlock() + + mp, exists := s.mounts[urlPrefix] + if !exists { + return fmt.Errorf("no mount point exists at %s", urlPrefix) + } + + // Close the mount point to release resources + if err := mp.Close(); err != nil { + return fmt.Errorf("failed to close mount point: %w", err) + } + + // Remove from the map + delete(s.mounts, urlPrefix) + + return nil +} + +// ListMounts returns a sorted list of all mounted URL prefixes. +func (s *service) ListMounts() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + prefixes := make([]string, 0, len(s.mounts)) + for prefix := range s.mounts { + prefixes = append(prefixes, prefix) + } + + sort.Strings(prefixes) + return prefixes +} + +// Reload reinitializes all filesystem providers that support reloading. +// This is useful when the underlying files have changed (e.g., zip file updated). +// Providers that implement ReloadableProvider will be reloaded. +func (s *service) Reload() error { + s.mu.RLock() + defer s.mu.RUnlock() + + var errors []error + + // Reload all mount points that support it + for prefix, mp := range s.mounts { + if reloadable, ok := mp.provider.(ReloadableProvider); ok { + if err := reloadable.Reload(); err != nil { + errors = append(errors, fmt.Errorf("%s: %w", prefix, err)) + } + } + } + + // Return combined errors if any + if len(errors) > 0 { + return fmt.Errorf("errors while reloading providers: %v", errors) + } + + return nil +} + +// Close releases all resources held by the service. +func (s *service) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + var errors []error + + // Close all mount points + for prefix, mp := range s.mounts { + if err := mp.Close(); err != nil { + errors = append(errors, fmt.Errorf("%s: %w", prefix, err)) + } + } + + // Clear the map + s.mounts = make(map[string]*mountPoint) + + // Return combined errors if any + if len(errors) > 0 { + return fmt.Errorf("errors while closing mount points: %v", errors) + } + + return nil +} + +// Handler returns an http.Handler that serves static files from all mount points. +func (s *service) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + + // Find the best matching mount point using longest-prefix matching + var bestMatch *mountPoint + var bestPrefix string + + for prefix, mp := range s.mounts { + if strings.HasPrefix(r.URL.Path, prefix) { + if len(prefix) > len(bestPrefix) { + bestMatch = mp + bestPrefix = prefix + } + } + } + + // If no mount point matches, return without writing a response + // This allows other handlers (like API routes) to process the request + if bestMatch == nil { + return + } + + // Serve the file from the matched mount point + bestMatch.ServeHTTP(w, r) + }) +} + +// validateMountConfig validates the mount configuration. +func (s *service) validateMountConfig(config MountConfig) error { + if config.URLPrefix == "" { + return fmt.Errorf("URLPrefix cannot be empty") + } + + if !strings.HasPrefix(config.URLPrefix, "/") { + return fmt.Errorf("URLPrefix must start with /") + } + + if config.Provider == nil { + return fmt.Errorf("Provider cannot be nil") + } + + return nil +} diff --git a/pkg/server/staticweb/service_test.go b/pkg/server/staticweb/service_test.go new file mode 100644 index 0000000..bc2885e --- /dev/null +++ b/pkg/server/staticweb/service_test.go @@ -0,0 +1,257 @@ +package staticweb + +import ( + "net/http" + "net/http/httptest" + "testing" + + staticwebtesting "github.com/bitechdev/ResolveSpec/pkg/server/staticweb/testing" +) + +func TestServiceMount(t *testing.T) { + service := NewService(nil) + + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("test"), + }) + + err := service.Mount(MountConfig{ + URLPrefix: "/test", + Provider: provider, + }) + + if err != nil { + t.Fatalf("Failed to mount: %v", err) + } + + mounts := service.ListMounts() + if len(mounts) != 1 || mounts[0] != "/test" { + t.Errorf("Expected [/test], got %v", mounts) + } +} + +func TestServiceUnmount(t *testing.T) { + service := NewService(nil) + + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("test"), + }) + + service.Mount(MountConfig{ + URLPrefix: "/test", + Provider: provider, + }) + + err := service.Unmount("/test") + if err != nil { + t.Fatalf("Failed to unmount: %v", err) + } + + mounts := service.ListMounts() + if len(mounts) != 0 { + t.Errorf("Expected empty list, got %v", mounts) + } +} + +func TestServiceHandler(t *testing.T) { + service := NewService(nil) + + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("test"), + "app.js": []byte("console.log('test')"), + }) + + err := service.Mount(MountConfig{ + URLPrefix: "/static", + Provider: provider, + }) + + if err != nil { + t.Fatalf("Failed to mount: %v", err) + } + + tests := []struct { + name string + path string + expectedStatus int + expectedBody string + }{ + { + name: "serve index.html", + path: "/static/index.html", + expectedStatus: http.StatusOK, + expectedBody: "test", + }, + { + name: "serve app.js", + path: "/static/app.js", + expectedStatus: http.StatusOK, + expectedBody: "console.log('test')", + }, + { + name: "non-existent file", + path: "/static/nonexistent.html", + expectedStatus: http.StatusNotFound, + expectedBody: "", + }, + { + name: "non-matching prefix returns nothing", + path: "/api/test", + expectedStatus: http.StatusOK, // Handler returns without writing + expectedBody: "", + }, + } + + handler := service.Handler() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + // For non-matching prefix, handler doesn't write anything + if tt.path == "/api/test" { + if rec.Code != 200 || rec.Body.Len() != 0 { + t.Errorf("Expected no response, got status %d with body length %d", rec.Code, rec.Body.Len()) + } + return + } + + if rec.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, rec.Code) + } + + if tt.expectedBody != "" && rec.Body.String() != tt.expectedBody { + t.Errorf("Expected body %q, got %q", tt.expectedBody, rec.Body.String()) + } + }) + } +} + +func TestServiceLongestPrefixMatching(t *testing.T) { + service := NewService(nil) + + // Mount at / + provider1 := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("root"), + }) + + // Mount at /static + provider2 := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("static"), + }) + + service.Mount(MountConfig{ + URLPrefix: "/", + Provider: provider1, + }) + + service.Mount(MountConfig{ + URLPrefix: "/static", + Provider: provider2, + }) + + handler := service.Handler() + + tests := []struct { + path string + expectedBody string + }{ + {"/index.html", "root"}, + {"/static/index.html", "static"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if rec.Body.String() != tt.expectedBody { + t.Errorf("Expected body %q, got %q", tt.expectedBody, rec.Body.String()) + } + }) + } +} + +func TestServiceClose(t *testing.T) { + service := NewService(nil) + + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("test"), + }) + + service.Mount(MountConfig{ + URLPrefix: "/test", + Provider: provider, + }) + + err := service.Close() + if err != nil { + t.Fatalf("Failed to close service: %v", err) + } + + mounts := service.ListMounts() + if len(mounts) != 0 { + t.Errorf("Expected empty list after close, got %v", mounts) + } +} + +func TestServiceReload(t *testing.T) { + service := NewService(nil) + + // Create a mock provider that supports reload + provider := staticwebtesting.NewMockProvider(map[string][]byte{ + "index.html": []byte("original"), + }) + + service.Mount(MountConfig{ + URLPrefix: "/test", + Provider: provider, + }) + + handler := service.Handler() + + // Test initial content + req := httptest.NewRequest("GET", "/test/index.html", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if rec.Body.String() != "original" { + t.Errorf("Expected body 'original', got %q", rec.Body.String()) + } + + // Update the provider's content + provider.AddFile("index.html", []byte("updated")) + + // The content is already updated since we're using a mock + // In a real scenario with zip files, you'd call Reload() here + err := service.Reload() + if err != nil { + t.Fatalf("Failed to reload service: %v", err) + } + + // Test updated content + req = httptest.NewRequest("GET", "/test/index.html", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if rec.Body.String() != "updated" { + t.Errorf("Expected body 'updated', got %q", rec.Body.String()) + } +} diff --git a/pkg/server/staticweb/testing/mocks.go b/pkg/server/staticweb/testing/mocks.go new file mode 100644 index 0000000..d8b366c --- /dev/null +++ b/pkg/server/staticweb/testing/mocks.go @@ -0,0 +1,231 @@ +package testing + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "path" + "strings" + "sync" + "time" +) + +// MockFileSystemProvider is an in-memory filesystem provider for testing. +type MockFileSystemProvider struct { + files map[string][]byte + closed bool + mu sync.RWMutex +} + +// NewMockProvider creates a new in-memory provider with the given files. +// Keys should be slash-separated paths (e.g., "index.html", "assets/app.js"). +func NewMockProvider(files map[string][]byte) *MockFileSystemProvider { + return &MockFileSystemProvider{ + files: files, + } +} + +// Open opens a file from the in-memory filesystem. +func (m *MockFileSystemProvider) Open(name string) (fs.File, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.closed { + return nil, fmt.Errorf("provider is closed") + } + + // Remove leading slash if present + name = strings.TrimPrefix(name, "/") + + data, ok := m.files[name] + if !ok { + return nil, os.ErrNotExist + } + + return &mockFile{ + name: path.Base(name), + data: data, + }, nil +} + +// Close marks the provider as closed. +func (m *MockFileSystemProvider) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + m.closed = true + return nil +} + +// Type returns "mock". +func (m *MockFileSystemProvider) Type() string { + return "mock" +} + +// AddFile adds a file to the in-memory filesystem. +func (m *MockFileSystemProvider) AddFile(name string, data []byte) { + m.mu.Lock() + defer m.mu.Unlock() + + name = strings.TrimPrefix(name, "/") + m.files[name] = data +} + +// RemoveFile removes a file from the in-memory filesystem. +func (m *MockFileSystemProvider) RemoveFile(name string) { + m.mu.Lock() + defer m.mu.Unlock() + + name = strings.TrimPrefix(name, "/") + delete(m.files, name) +} + +// mockFile implements fs.File for in-memory files. +type mockFile struct { + name string + data []byte + reader *bytes.Reader + offset int64 +} + +func (f *mockFile) Stat() (fs.FileInfo, error) { + return &mockFileInfo{ + name: f.name, + size: int64(len(f.data)), + }, nil +} + +func (f *mockFile) Read(p []byte) (int, error) { + if f.reader == nil { + f.reader = bytes.NewReader(f.data) + if f.offset > 0 { + f.reader.Seek(f.offset, io.SeekStart) + } + } + n, err := f.reader.Read(p) + f.offset += int64(n) + return n, err +} + +func (f *mockFile) Seek(offset int64, whence int) (int64, error) { + if f.reader == nil { + f.reader = bytes.NewReader(f.data) + } + pos, err := f.reader.Seek(offset, whence) + f.offset = pos + return pos, err +} + +func (f *mockFile) Close() error { + return nil +} + +// mockFileInfo implements fs.FileInfo. +type mockFileInfo struct { + name string + size int64 +} + +func (fi *mockFileInfo) Name() string { return fi.name } +func (fi *mockFileInfo) Size() int64 { return fi.size } +func (fi *mockFileInfo) Mode() fs.FileMode { return 0644 } +func (fi *mockFileInfo) ModTime() time.Time { return time.Now() } +func (fi *mockFileInfo) IsDir() bool { return false } +func (fi *mockFileInfo) Sys() interface{} { return nil } + +// MockCachePolicy is a configurable cache policy for testing. +type MockCachePolicy struct { + CacheTime int + Headers map[string]string +} + +// NewMockCachePolicy creates a new mock cache policy. +func NewMockCachePolicy(cacheTime int) *MockCachePolicy { + return &MockCachePolicy{ + CacheTime: cacheTime, + Headers: make(map[string]string), + } +} + +// GetCacheTime returns the configured cache time. +func (p *MockCachePolicy) GetCacheTime(path string) int { + return p.CacheTime +} + +// GetCacheHeaders returns the configured headers. +func (p *MockCachePolicy) GetCacheHeaders(path string) map[string]string { + if p.Headers != nil { + return p.Headers + } + return map[string]string{ + "Cache-Control": fmt.Sprintf("public, max-age=%d", p.CacheTime), + } +} + +// MockMIMEResolver is a configurable MIME resolver for testing. +type MockMIMEResolver struct { + types map[string]string + mu sync.RWMutex +} + +// NewMockMIMEResolver creates a new mock MIME resolver. +func NewMockMIMEResolver() *MockMIMEResolver { + return &MockMIMEResolver{ + types: make(map[string]string), + } +} + +// GetMIMEType returns the MIME type for the given path. +func (r *MockMIMEResolver) GetMIMEType(path string) string { + r.mu.RLock() + defer r.mu.RUnlock() + + ext := strings.ToLower(path[strings.LastIndex(path, "."):]) + if mimeType, ok := r.types[ext]; ok { + return mimeType + } + return "application/octet-stream" +} + +// RegisterMIMEType registers a MIME type. +func (r *MockMIMEResolver) RegisterMIMEType(extension, mimeType string) { + r.mu.Lock() + defer r.mu.Unlock() + + if !strings.HasPrefix(extension, ".") { + extension = "." + extension + } + r.types[strings.ToLower(extension)] = mimeType +} + +// MockFallbackStrategy is a configurable fallback strategy for testing. +type MockFallbackStrategy struct { + ShouldFallbackFunc func(path string) bool + FallbackPathFunc func(path string) string +} + +// NewMockFallbackStrategy creates a new mock fallback strategy. +func NewMockFallbackStrategy(shouldFallback func(string) bool, fallbackPath func(string) string) *MockFallbackStrategy { + return &MockFallbackStrategy{ + ShouldFallbackFunc: shouldFallback, + FallbackPathFunc: fallbackPath, + } +} + +// ShouldFallback returns whether fallback should be used. +func (s *MockFallbackStrategy) ShouldFallback(path string) bool { + if s.ShouldFallbackFunc != nil { + return s.ShouldFallbackFunc(path) + } + return false +} + +// GetFallbackPath returns the fallback path. +func (s *MockFallbackStrategy) GetFallbackPath(path string) string { + if s.FallbackPathFunc != nil { + return s.FallbackPathFunc(path) + } + return "index.html" +} diff --git a/pkg/server/zipfs/zipfs.go b/pkg/server/zipfs/zipfs.go new file mode 100644 index 0000000..c547a1a --- /dev/null +++ b/pkg/server/zipfs/zipfs.go @@ -0,0 +1,112 @@ +package zipfs + +import ( + "archive/zip" + "fmt" + "io" + "io/fs" + "os" +) + +type ZipFS struct { + *zip.Reader +} + +func NewZipFS(r *zip.Reader) *ZipFS { + return &ZipFS{r} +} + +func (z *ZipFS) Open(name string) (fs.File, error) { + for _, f := range z.File { + if f.Name == name { + rc, err := f.Open() + if err != nil { + return nil, err + } + return &ZipFile{f, rc, 0}, nil + } + } + return nil, os.ErrNotExist +} + +type ZipFile struct { + *zip.File + rc io.ReadCloser + offset int64 +} + +func (f *ZipFile) Stat() (fs.FileInfo, error) { + if f.File != nil { + return f.File.FileInfo(), nil + } + return nil, fmt.Errorf("No file") +} + +func (f *ZipFile) Close() error { + if f.rc != nil { + return f.rc.Close() + } + return nil +} + +func (f *ZipFile) Read(b []byte) (int, error) { + if f.rc == nil { + var err error + f.rc, err = f.File.Open() + if err != nil { + return 0, err + } + } + n, err := f.rc.Read(b) + f.offset += int64(n) + if err == io.EOF { + f.rc.Close() + f.rc = nil + } + return n, err + +} +func (f *ZipFile) Seek(offset int64, whence int) (int64, error) { + if f.rc != nil { + f.rc.Close() + f.rc = nil + } + switch whence { + case io.SeekStart: + if offset < 0 { + return 0, &fs.PathError{Op: "seek", Path: f.Name, Err: fmt.Errorf("negative position")} + } + f.offset = offset + case io.SeekCurrent: + if f.offset+offset < 0 { + return 0, &fs.PathError{Op: "seek", Path: f.Name, Err: fmt.Errorf("negative position")} + } + f.offset += offset + case io.SeekEnd: + size := int64(f.File.UncompressedSize64) + if size+offset < 0 { + return 0, &fs.PathError{Op: "seek", Path: f.Name, Err: fmt.Errorf("negative position")} + } + f.offset = size + offset + } + return f.offset, nil +} + +/* +func main() { + r, err := zip.OpenReader("path/to/your.zip") + if err != nil { + log.Fatal(err) + } + defer r.Close() + + fs := NewZipFS(&r.Reader) + file, err := fs.Open(path.Join("path", "to", "file")) + if err != nil { + log.Fatal(err) + } + defer file.Close() + + // Now you can use 'file' as a fs.File +} +*/