Skip to content

Commit aca438e

Browse files
Revamp the SPA serving interface.
Big help from Gemini.
1 parent 91e1a2c commit aca438e

17 files changed

+2152
-28
lines changed

README.md

Lines changed: 337 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,342 @@
1-
# Go SPA Serve
1+
# Go SPA Serve
2+
3+
[![Go Report Card](https://goreportcard.com/badge/github.com/jrschumacher/go-spaserve)](https://goreportcard.com/report/github.com/jrschumacher/go-spaserve)
24
[![codecov](https://codecov.io/gh/jrschumacher/go-spaserve/graph/badge.svg?token=W99WAK10IX)](https://codecov.io/gh/jrschumacher/go-spaserve)
5+
[![Go Reference](https://pkg.go.dev/badge/github.com/jrschumacher/go-spaserve.svg)](https://pkg.go.dev/github.com/jrschumacher/go-spaserve)
6+
7+
**`go-spaserve` is a flexible Go package designed to serve static files and Single Page Applications (SPAs) with capabilities for runtime file modifications, such as injecting environment variables.**
8+
9+
It provides an `http.Handler` that intelligently serves files from a given `io/fs.FS`, handles SPA routing by serving a fallback file (like `index.html`) for unknown paths, and allows for targeted modifications to specific files before they are served. Modifications are performed lazily (on first request) and can be cached for performance.
10+
11+
## Motivation
12+
13+
Modern web development often involves build tools (like Vite, Webpack, etc.) that bundle application assets. While excellent for development and optimization, they often bake in build-time configurations. This presents challenges when:
14+
15+
1. Building a single container image intended for multiple deployment environments (dev, staging, prod) with different runtime configurations (e.g., API endpoints).
16+
2. Deploying applications on-premises where environment details aren't known until deployment time.
17+
18+
`go-spaserve` addresses this by allowing you to serve your pre-built static assets while enabling targeted, server-side modifications at runtime. The most common use case is injecting runtime environment variables directly into your `index.html` or configuration JavaScript files *after* the application has been built.
19+
20+
## Features
21+
22+
* **SPA Routing:** Correctly serves a fallback HTML file (e.g., `index.html`) for paths that don't match static files, allowing client-side routers to take over.
23+
* **Static File Serving:** Efficiently serves other static assets (JS, CSS, images) from any `io/fs.FS` (including `embed.FS` and `os.DirFS`).
24+
* **Runtime File Modification:** Define specific files (`TargetFile`) within the `fs.FS` to be modified *before* serving using custom `FileModifier` implementations.
25+
* **HTML Script Injection:** Includes a built-in `HtmlScriptTagEnvModifier` to easily inject Go data structures (as JSON) into HTML `<head>` sections (e.g., `window.APP_CONFIG = {...};`).
26+
* **Composable Modifiers:** Chain multiple modifications together for a single file using the `CompositeModifier`.
27+
* **Configurable Caching:** Modified files can be automatically cached in memory to avoid reprocessing on subsequent requests.
28+
* **Base Path Handling:** Serve the entire application under a specific URL prefix (e.g., `/myapp/`).
29+
* **Customizable Logging:** Integrates with `log/slog` for structured logging.
30+
* **Customizable Error Handling:** Provide your own handler for HTTP errors (404, 500).
31+
* **Interface-Based:** Core components like `FileModifier` and `Cache` are interface-based for testability and extensibility.
32+
33+
## Installation
34+
35+
```bash
36+
go get github.com/jrschumacher/go-spaserve
37+
```
38+
39+
## Usage
40+
41+
The primary entrypoint is `spaserve.NewSpaServer(config)`, which takes a configuration struct and returns an `http.Handler`.
42+
43+
### Example 1: Basic SPA Server (No Modifications)
44+
45+
This example serves an embedded filesystem, falling back to `index.html` for unknown paths.
46+
47+
```go
48+
package main
49+
50+
import (
51+
"embed"
52+
"io/fs"
53+
"log"
54+
"net/http"
55+
56+
"github.com/jrschumacher/go-spaserve"
57+
)
58+
59+
//go:embed dist/*
60+
var embeddedFiles embed.FS
61+
62+
func main() {
63+
// Get the subdirectory containing the files
64+
distFS, err := fs.Sub(embeddedFiles, "dist")
65+
if err != nil {
66+
log.Fatal("Failed to get sub filesystem:", err)
67+
}
68+
69+
// Configure the SPA server
70+
config := spaserve.SpaServerConfig{
71+
FS: distFS,
72+
// SpaFallbackPath defaults to "index.html"
73+
// BasePath defaults to "/"
74+
}
75+
76+
spaHandler, err := spaserve.NewSpaServer(config)
77+
if err != nil {
78+
log.Fatal("Failed to create SPA handler:", err)
79+
}
80+
81+
mux := http.NewServeMux()
82+
mux.Handle("/", spaHandler) // Handle all requests
83+
84+
log.Println("Starting server on :8080...")
85+
if err := http.ListenAndServe(":8080", mux); err != nil {
86+
log.Fatal(err)
87+
}
88+
}
89+
```
90+
91+
### Example 2: SPA with Runtime Environment Injection
92+
93+
Injects a Go struct into `index.html` as `window.APP_CONFIG`.
94+
95+
```go
96+
package main
97+
98+
import (
99+
"embed"
100+
"io/fs"
101+
"log"
102+
"net/http"
103+
"os" // For reading env vars
104+
105+
"github.com/jrschumacher/go-spaserve"
106+
)
107+
108+
//go:embed dist/*
109+
var embeddedFiles embed.FS
110+
111+
// Define the structure for your frontend configuration
112+
type AppConfig struct {
113+
ApiEndpoint string `json:"apiEndpoint"`
114+
FeatureFlag bool `json:"featureFlag"`
115+
Theme string `json:"theme"`
116+
}
117+
118+
func main() {
119+
distFS, err := fs.Sub(embeddedFiles, "dist")
120+
if err != nil {
121+
log.Fatal("Failed to get sub filesystem:", err)
122+
}
123+
124+
// Load runtime configuration (e.g., from environment variables)
125+
appEnv := AppConfig{
126+
ApiEndpoint: os.Getenv("API_ENDPOINT"), // Example: Load from env
127+
FeatureFlag: os.Getenv("ENABLE_FEATURE_X") == "true",
128+
Theme: "dark", // Example: Could also come from env
129+
}
130+
131+
// Create the HTML script modifier
132+
// This helper validates the namespace.
133+
htmlModifier, err := spaserve.NewHtmlScriptTagEnvModifier(appEnv, "APP_CONFIG")
134+
if err != nil {
135+
log.Fatalf("Failed to create HTML modifier: %v", err)
136+
}
137+
138+
// Configure the SPA server with a target for modification
139+
config := spaserve.SpaServerConfig{
140+
FS: distFS,
141+
Targets: []spaserve.TargetConfig{
142+
{
143+
TargetFile: "index.html", // Specify the file to modify (relative to FS root)
144+
Modifier: htmlModifier, // The modifier to apply
145+
CacheResult: true, // Cache the modified index.html
146+
},
147+
},
148+
// SpaFallbackPath defaults to "index.html"
149+
// BasePath defaults to "/"
150+
}
151+
152+
spaHandler, err := spaserve.NewSpaServer(config)
153+
if err != nil {
154+
log.Fatal("Failed to create SPA handler:", err)
155+
}
156+
157+
mux := http.NewServeMux()
158+
mux.Handle("/", spaHandler)
159+
160+
log.Println("Starting server with env injection on :8080...")
161+
if err := http.ListenAndServe(":8080", mux); err != nil {
162+
log.Fatal(err)
163+
}
164+
}
165+
166+
```
167+
168+
### Example 3: Composite Modifications
169+
170+
Inject multiple variables or apply different modifications to the same file.
171+
172+
```go
173+
package main
174+
175+
import (
176+
// ... other imports from Example 2
177+
"github.com/jrschumacher/go-spaserve"
178+
)
179+
180+
// ... embed, AppConfig struct etc.
181+
182+
func main() {
183+
distFS, err := fs.Sub(embeddedFiles, "dist")
184+
// ... handle error
185+
186+
// Runtime data
187+
appEnv := AppConfig{ /* ... populate ... */ }
188+
userSession := map[string]string{"userId": "user-123", "role": "admin"}
189+
190+
// Create individual modifiers
191+
modifier1, err1 := spaserve.NewHtmlScriptTagEnvModifier(appEnv, "APP_CONFIG")
192+
modifier2, err2 := spaserve.NewHtmlScriptTagEnvModifier(userSession, "USER_SESSION")
193+
if err1 != nil || err2 != nil {
194+
log.Fatalf("Failed to create modifiers: %v, %v", err1, err2)
195+
}
196+
197+
// Combine them using a CompositeModifier
198+
compositeModifier := spaserve.CreateCompositeModifier(modifier1, modifier2)
199+
200+
config := spaserve.SpaServerConfig{
201+
FS: distFS,
202+
Targets: []spaserve.TargetConfig{
203+
{
204+
TargetFile: "index.html",
205+
Modifier: compositeModifier, // Use the composite modifier
206+
CacheResult: true,
207+
},
208+
// You could add other targets here for different files
209+
// {
210+
// TargetFile: "assets/config.js",
211+
// Modifier: myCustomJsModifier,
212+
// CacheResult: true,
213+
// },
214+
},
215+
}
216+
217+
spaHandler, err := spaserve.NewSpaServer(config)
218+
// ... handle error, start server ...
219+
}
220+
221+
```
222+
223+
### Example 4: Serving from OS Filesystem with Base Path
224+
225+
```go
226+
package main
227+
228+
import (
229+
"log"
230+
"net/http"
231+
"os" // Use os.DirFS
232+
233+
"github.com/jrschumacher/go-spaserve"
234+
)
235+
236+
237+
func main() {
238+
// Serve files from the local "./static-assets" directory
239+
osFS := os.DirFS("./static-assets")
240+
241+
// Configure the SPA server
242+
config := spaserve.SpaServerConfig{
243+
FS: osFS,
244+
// Serve everything under the /myapp/ URL prefix
245+
// Must start and end with '/' unless it's just "/"
246+
BasePath: "/myapp/",
247+
// SpaFallbackPath defaults to "index.html"
248+
}
249+
250+
spaHandler, err := spaserve.NewSpaServer(config)
251+
if err != nil {
252+
log.Fatal("Failed to create SPA handler:", err)
253+
}
254+
255+
mux := http.NewServeMux()
256+
// IMPORTANT: The pattern here must match the BasePath!
257+
mux.Handle(config.BasePath, spaHandler)
258+
259+
log.Println("Starting OS FS server on :8080 under /myapp/ ...")
260+
if err := http.ListenAndServe(":8080", mux); err != nil {
261+
log.Fatal(err)
262+
}
263+
}
264+
```
265+
266+
## Configuration (`SpaServerConfig`)
267+
268+
The `NewSpaServer` function accepts a `SpaServerConfig` struct with the following fields:
269+
270+
* **`FS fs.FS`** (Required): The filesystem containing your static assets. Use `embed.FS`, `os.DirFS`, or any other `fs.FS` implementation.
271+
* **`Targets []TargetConfig`** (Optional): A slice defining which files to modify. Each `TargetConfig` has:
272+
* `TargetFile string`: Path within the `FS` to modify (e.g., `"index.html"`, `"assets/config.js"`). Paths are cleaned internally.
273+
* `Modifier FileModifier`: An implementation of the `FileModifier` interface responsible for transforming the file content. Use `NewHtmlScriptTagEnvModifier` or implement your own.
274+
* `CacheResult bool`: If `true`, the result of the `Modifier` is cached in memory after the first request. If `false`, the `Modifier` runs on every request for that file.
275+
* **`SpaFallbackPath string`** (Optional): The path (relative to the `FS` root) to serve when a requested path looks like an SPA route (doesn't exist as a file and has no file extension).
276+
* Defaults to `"index.html"`.
277+
* **`BasePath string`** (Optional): A URL prefix under which the application is served. Requests must start with this path, and the prefix is stripped before looking up files in the `FS`.
278+
* Defaults to `"/"`.
279+
* **Important:** If set to anything other than `/`, it *must* start and end with a `/` (e.g., `/myapp/`, `/admin/ui/`).
280+
* **`Logger *slog.Logger`** (Optional): A structured logger instance.
281+
* Defaults to `slog.Default()`.
282+
* **`MuxErrorHandler func(statusCode int, w http.ResponseWriter, r *http.Request)`** (Optional): A function to handle HTTP errors generated by the `spaserve` handlers (e.g., 500 on modification failure, 404 if a targeted file isn't found in the source FS).
283+
* Defaults to a simple `http.Error` response.
284+
285+
## Extensibility (`FileModifier` Interface)
286+
287+
You can create custom file modifications by implementing the `FileModifier` interface:
288+
289+
```go
290+
package spaserve
291+
292+
type FileModifier interface {
293+
// Modify takes the original file path and content,
294+
// returns the modified content or an error.
295+
Modify(path string, originalContent []byte) (modifiedContent []byte, err error)
296+
}
297+
```
298+
299+
**Example: Placeholder Replacer**
300+
301+
```go
302+
package main
303+
304+
import (
305+
"bytes"
306+
"fmt"
307+
308+
"github.com/jrschumacher/go-spaserve" // Assuming used within the same project structure
309+
)
310+
311+
type PlaceholderModifier struct {
312+
Placeholder string
313+
Value string
314+
}
315+
316+
func (pm *PlaceholderModifier) Modify(path string, originalContent []byte) ([]byte, error) {
317+
// Simple, potentially inefficient replacement for demonstration
318+
modified := bytes.ReplaceAll(originalContent, []byte(pm.Placeholder), []byte(pm.Value))
319+
if bytes.Equal(modified, originalContent) {
320+
fmt.Printf("Warning: Placeholder %q not found in file %q\n", pm.Placeholder, path)
321+
}
322+
return modified, nil
323+
}
324+
325+
// Usage in SpaServerConfig:
326+
// ...
327+
// versionModifier := &PlaceholderModifier{ Placeholder: "__APP_VERSION__", Value: "1.2.3" }
328+
// config.Targets = []spaserve.TargetConfig {
329+
// { TargetFile: "main.js", Modifier: versionModifier, CacheResult: true },
330+
// }
331+
// ...
332+
```
333+
334+
This allows for various transformations like replacing placeholders, modifying CSS variables, processing template files, etc., all performed server-side at runtime.
3335

4-
Go SPA Serve is a simple package that serves static files specifically focused on serving single page applications (SPA).
5-
Generally, this can be used to instead of the stdlib `http.FileServer`, but the main use case is to serve a SPA with a
6-
single entry point (e.g. `index.html`) and let the client-side router handle the rest.
336+
## Contributing
7337

8-
In addition to serving static files, this package also provides a way to load environment variables from a Go struct at
9-
runtime by injecting them into the head tag of the served HTML file.
338+
Contributions are welcome! Please feel free to submit issues or pull requests.
10339

11-
## Problem
340+
## License
12341

13-
Vite is a fantastic build tool, but it doesn't support loading environment variables at runtime.
14-
This becomes quite a problem when you build a single image for multiple environments or need to
15-
build images for on-premises deployments where you can't bake in the environment variables.
342+
This project is licensed under the MIT License - see the LICENSE file for details.

cache.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package spaserve
2+
3+
import "sync"
4+
5+
// MemoryCache provides a thread-safe in-memory cache using sync.Map.
6+
type MemoryCache struct {
7+
store sync.Map
8+
}
9+
10+
// NewMemoryCache creates a new empty MemoryCache.
11+
func NewMemoryCache() *MemoryCache {
12+
return &MemoryCache{}
13+
}
14+
15+
// Get retrieves an item from the cache.
16+
func (mc *MemoryCache) Get(key string) (data []byte, found bool) {
17+
value, ok := mc.store.Load(key)
18+
if !ok {
19+
return nil, false
20+
}
21+
// Assume stored value is []byte, otherwise panic (indicates misuse)
22+
data, ok = value.([]byte)
23+
if !ok {
24+
// This should not happen if Set only stores []byte
25+
panic("spaserve: MemoryCache stored non-[]byte value")
26+
}
27+
return data, true
28+
}
29+
30+
// Set adds or updates an item in the cache.
31+
func (mc *MemoryCache) Set(key string, data []byte) {
32+
// Store a copy to prevent external modification of the cached slice
33+
dataCopy := make([]byte, len(data))
34+
copy(dataCopy, data)
35+
mc.store.Store(key, dataCopy)
36+
}
37+
38+
// Ensure MemoryCache implements the interface (compile-time check)
39+
var _ Cache = (*MemoryCache)(nil)

0 commit comments

Comments
 (0)