Skip to content

Commit 084ac2b

Browse files
committed
feat(base-path): add subpath SPA mapping and cache-control support
Add a runtime --base-path/BASE_PATH option with normalization and default '/' to support serving SPAs behind URL prefixes (for example, /app). Map prefixed request paths to the filesystem root while preserving root fallback behavior, SPA routing, compression, and traversal safety. Update IgnoreCacheControlPaths matching to check both raw incoming paths and mapped internal paths under base-path deployments. Add comprehensive tests for param normalization and app path mapping behavior, including /app canonical handling, asset lookup, traversal rejection, and outside-prefix fallback; touched package coverage is 100% for app and param. Document the feature in README and deployment/config/development docs with mapping examples and cache-control guidance.
1 parent 19ca7cf commit 084ac2b

File tree

8 files changed

+362
-3
lines changed

8 files changed

+362
-3
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ docker run --rm -p 8080:8080 \
8181
devforth/spa-to-http:latest
8282
```
8383

84+
### Subpath hosting (`/app`)
85+
86+
```bash
87+
docker run --rm -p 8080:8080 \
88+
-v $(pwd)/dist:/code \
89+
devforth/spa-to-http:latest \
90+
--base-path /app
91+
```
92+
93+
This maps `/app/...` requests to the same build root (for example `/app/assets/main.js` -> `/code/assets/main.js`).
94+
8495
### Compose / reverse proxy setup
8596

8697
For full Docker Compose and Traefik examples, see [`docs/deployment.md`](docs/deployment.md).
@@ -90,6 +101,7 @@ For full Docker Compose and Traefik examples, see [`docs/deployment.md`](docs/de
90101
- Zero-configuration Docker usage for SPA bundles
91102
- Optional Brotli/Gzip compression
92103
- Cache-control tuning (`--cache-max-age`, `--ignore-cache-control-paths`)
104+
- Subpath hosting with URL prefixes (`--base-path`)
93105
- SPA mode toggle (`--spa` / `SPA_MODE`)
94106
- In-memory file cache (`--cache`, `--cache-buffer`)
95107
- Optional request logging and basic auth

docs/configuration.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
| BROTLI | `--brotli` | Enable Brotli compression for files above the threshold | `false` |
1515
| THRESHOLD | `--threshold <number>` | Threshold in bytes for gzip and Brotli | `1024` |
1616
| DIRECTORY | `-d <string>` or `--directory <string>` | Directory to serve | `.` |
17+
| BASE_PATH | `--base-path <string>` | URL prefix to mount the SPA under (normalized path prefix) | `/` |
1718
| CACHE_MAX_AGE | `--cache-max-age <number>` | Cache max-age in seconds; use `-1` to disable | `604800` |
1819
| IGNORE_CACHE_CONTROL_PATHS | `--ignore-cache-control-paths <string>` | Comma-separated paths to force `Cache-Control: no-store` | (empty) |
1920
| SPA_MODE | `--spa` or `--spa <bool>` | Serve `index.html` on missing paths (SPA routing) | `true` |
@@ -69,6 +70,46 @@ services:
6970
command: --ignore-cache-control-paths "/sw.js"
7071
```
7172
73+
## Base Path (Subpath Hosting)
74+
75+
Use `--base-path` when your SPA is exposed behind a URL prefix (for example, `/app`) while files stay in the same build directory.
76+
77+
### Normalization Rules
78+
79+
- Empty value becomes `/`
80+
- Missing leading slash is added (`app` -> `/app`)
81+
- Trailing slash is removed (`/app/` -> `/app`)
82+
- Query strings and fragments are invalid (`/app?a=1`, `/app#x`)
83+
84+
### Request Mapping
85+
86+
With `--base-path /app` and `--directory /code`:
87+
88+
- `/app` -> `/code/index.html`
89+
- `/app/` -> `/code/index.html`
90+
- `/app/assets/main.js` -> `/code/assets/main.js`
91+
- `/app/route1` -> SPA fallback to `/code/index.html` (when `--spa` is enabled)
92+
93+
Requests outside the base path continue to follow normal root behavior:
94+
95+
- `/assets/main.js` -> `/code/assets/main.js`
96+
97+
### Interaction with `IGNORE_CACHE_CONTROL_PATHS`
98+
99+
When `--base-path` is set, ignore paths are matched against both:
100+
101+
1. Raw incoming request path (for example, `/app/sw.js`)
102+
2. Mapped internal path (for example, `/sw.js`)
103+
104+
That means either style works:
105+
106+
```yaml
107+
services:
108+
spa:
109+
image: devforth/spa-to-http:latest
110+
command: --base-path /app --ignore-cache-control-paths "/sw.js,/app/sw.js"
111+
```
112+
72113
## See Also
73114

74115
- [Getting Started](getting-started.md) — Install, build, and run

docs/deployment.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,34 @@ services:
3333
3434
- Use `--port` if you run the container on a non-default port.
3535
- Enable compression (`--brotli` or `--gzip`) when serving large static bundles.
36+
- Use `--base-path` when the SPA is mounted under a subpath (for example, `/app`) behind your proxy.
3637
- For fixed asset paths (for example, a service worker), use `--ignore-cache-control-paths` to avoid CDN caching issues.
3738
- Add rate limiting at the reverse proxy (Traefik, Nginx, Cloudflare) to mitigate brute-force attempts.
3839

40+
## Subpath Deployment (`--base-path`)
41+
42+
When your reverse proxy exposes the app at a subpath such as `/app`, configure:
43+
44+
```yaml
45+
services:
46+
spa:
47+
image: devforth/spa-to-http:latest
48+
command: --base-path /app
49+
```
50+
51+
Behavior:
52+
- `/app` and `/app/` serve `index.html`
53+
- `/app/assets/...` maps to assets from the same dist root
54+
- SPA routes under `/app/...` fall back to `index.html` (with `--spa=true`)
55+
56+
### `--ignore-cache-control-paths` with `--base-path`
57+
58+
Ignore paths are matched against both:
59+
- Raw incoming path (for example, `/app/sw.js`)
60+
- Internal mapped path (for example, `/sw.js`)
61+
62+
So either notation works in deployment config.
63+
3964
## See Also
4065

4166
- [Configuration](configuration.md) — Environment variables and CLI flags

docs/development.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ go run . \
4040
--logger \
4141
--log-pretty \
4242
--cache-max-age 3600 \
43-
--threshold 2048
43+
--threshold 2048 \
44+
--base-path /app
4445
```
4546

4647
## Configure via Environment Variables

src/app/app.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,24 @@ func (app *App) GetFilePath(urlPath string) (string, bool) {
261261
return requestedPath, true
262262
}
263263

264+
func (app *App) mapRequestPath(urlPath string) string {
265+
basePath := app.params.BasePath
266+
if basePath == "" || basePath == "/" {
267+
return urlPath
268+
}
269+
if urlPath == basePath || urlPath == basePath+"/" {
270+
return "/"
271+
}
272+
basePathWithSlash := basePath + "/"
273+
if strings.HasPrefix(urlPath, basePathWithSlash) {
274+
return strings.TrimPrefix(urlPath, basePath)
275+
}
276+
return urlPath
277+
}
278+
264279
func (app *App) HandlerFuncNew(w http.ResponseWriter, r *http.Request) {
265-
requestedPath, valid := app.GetFilePath(r.URL.Path)
280+
mappedRequestPath := app.mapRequestPath(r.URL.Path)
281+
requestedPath, valid := app.GetFilePath(mappedRequestPath)
266282

267283
if !valid {
268284
w.WriteHeader(http.StatusNotFound)
@@ -283,7 +299,9 @@ func (app *App) HandlerFuncNew(w http.ResponseWriter, r *http.Request) {
283299
return
284300
}
285301

286-
if slices.Contains(app.params.IgnoreCacheControlPaths, r.URL.Path) || path.Ext(responseItem.Name) == ".html" {
302+
if slices.Contains(app.params.IgnoreCacheControlPaths, r.URL.Path) ||
303+
slices.Contains(app.params.IgnoreCacheControlPaths, mappedRequestPath) ||
304+
path.Ext(responseItem.Name) == ".html" {
287305
w.Header().Set("Cache-Control", "no-store")
288306
} else {
289307
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", app.params.CacheControlMaxAge))

src/app/app_internal_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"io/fs"
77
"net/http"
8+
"net/http/httptest"
89
"os"
910
"path/filepath"
1011
"testing"
@@ -242,3 +243,162 @@ func TestGetOrCreateResponseItemDirWithCompressionNotFound(t *testing.T) {
242243
t.Fatalf("expected status %d, got %d", http.StatusNotFound, code)
243244
}
244245
}
246+
247+
func TestMapRequestPath(t *testing.T) {
248+
params := param.Params{
249+
Directory: ".",
250+
BasePath: "/app",
251+
}
252+
app := NewApp(&params)
253+
254+
tests := []struct {
255+
name string
256+
in string
257+
want string
258+
}{
259+
{name: "base path exact", in: "/app", want: "/"},
260+
{name: "base path with trailing slash", in: "/app/", want: "/"},
261+
{name: "asset under base path", in: "/app/assets/main.js", want: "/assets/main.js"},
262+
{name: "outside prefix fallback", in: "/assets/main.js", want: "/assets/main.js"},
263+
{name: "prefix-like but not matching", in: "/application/main.js", want: "/application/main.js"},
264+
}
265+
266+
for _, tt := range tests {
267+
t.Run(tt.name, func(t *testing.T) {
268+
got := app.mapRequestPath(tt.in)
269+
if got != tt.want {
270+
t.Fatalf("expected %q, got %q", tt.want, got)
271+
}
272+
})
273+
}
274+
}
275+
276+
func TestMapRequestPathRootBasePathNoop(t *testing.T) {
277+
params := param.Params{
278+
Directory: ".",
279+
BasePath: "/",
280+
}
281+
app := NewApp(&params)
282+
283+
got := app.mapRequestPath("/app/assets/main.js")
284+
if got != "/app/assets/main.js" {
285+
t.Fatalf("expected path unchanged, got %q", got)
286+
}
287+
}
288+
289+
func TestMapRequestPathEmptyBasePathNoop(t *testing.T) {
290+
params := param.Params{
291+
Directory: ".",
292+
BasePath: "",
293+
}
294+
app := NewApp(&params)
295+
296+
got := app.mapRequestPath("/app/assets/main.js")
297+
if got != "/app/assets/main.js" {
298+
t.Fatalf("expected path unchanged, got %q", got)
299+
}
300+
}
301+
302+
func TestHandlerFuncNewBasePathCanonicalIndex(t *testing.T) {
303+
dir := t.TempDir()
304+
indexPath := filepath.Join(dir, "index.html")
305+
if err := os.WriteFile(indexPath, []byte("index"), 0600); err != nil {
306+
t.Fatalf("failed to write index: %v", err)
307+
}
308+
309+
params := param.Params{
310+
Directory: dir,
311+
BasePath: "/app",
312+
SpaMode: true,
313+
}
314+
app := NewApp(&params)
315+
316+
for _, reqPath := range []string{"/app", "/app/"} {
317+
req := httptest.NewRequest("GET", reqPath, nil)
318+
rec := httptest.NewRecorder()
319+
app.HandlerFuncNew(rec, req)
320+
321+
if rec.Code != http.StatusOK {
322+
t.Fatalf("expected status 200 for %s, got %d", reqPath, rec.Code)
323+
}
324+
if rec.Body.String() != "index" {
325+
t.Fatalf("expected index body for %s, got %q", reqPath, rec.Body.String())
326+
}
327+
}
328+
}
329+
330+
func TestHandlerFuncNewBasePathAssetMappingAndIgnoreCacheControl(t *testing.T) {
331+
dir := t.TempDir()
332+
assetPath := filepath.Join(dir, "asset.bin")
333+
if err := os.WriteFile(assetPath, []byte("asset"), 0600); err != nil {
334+
t.Fatalf("failed to write asset: %v", err)
335+
}
336+
337+
params := param.Params{
338+
Directory: dir,
339+
BasePath: "/app",
340+
SpaMode: true,
341+
IgnoreCacheControlPaths: []string{"/asset.bin"},
342+
CacheControlMaxAge: 3600,
343+
}
344+
app := NewApp(&params)
345+
346+
req := httptest.NewRequest("GET", "/app/asset.bin", nil)
347+
rec := httptest.NewRecorder()
348+
app.HandlerFuncNew(rec, req)
349+
350+
if rec.Code != http.StatusOK {
351+
t.Fatalf("expected status 200, got %d", rec.Code)
352+
}
353+
if rec.Header().Get("Cache-Control") != "no-store" {
354+
t.Fatalf("expected no-store cache control, got %s", rec.Header().Get("Cache-Control"))
355+
}
356+
if rec.Body.String() != "asset" {
357+
t.Fatalf("expected asset body, got %q", rec.Body.String())
358+
}
359+
}
360+
361+
func TestHandlerFuncNewBasePathTraversalRejected(t *testing.T) {
362+
dir := t.TempDir()
363+
params := param.Params{
364+
Directory: dir,
365+
BasePath: "/app",
366+
SpaMode: true,
367+
}
368+
app := NewApp(&params)
369+
370+
req := httptest.NewRequest("GET", "/app/../secret.txt", nil)
371+
rec := httptest.NewRecorder()
372+
app.HandlerFuncNew(rec, req)
373+
374+
if rec.Code != http.StatusNotFound {
375+
t.Fatalf("expected 404, got %d", rec.Code)
376+
}
377+
}
378+
379+
func TestHandlerFuncNewBasePathOutsidePrefixFallsBackToRoot(t *testing.T) {
380+
dir := t.TempDir()
381+
assetPath := filepath.Join(dir, "asset.bin")
382+
if err := os.WriteFile(assetPath, []byte("asset"), 0600); err != nil {
383+
t.Fatalf("failed to write asset: %v", err)
384+
}
385+
386+
params := param.Params{
387+
Directory: dir,
388+
BasePath: "/app",
389+
SpaMode: true,
390+
CacheControlMaxAge: 3600,
391+
}
392+
app := NewApp(&params)
393+
394+
req := httptest.NewRequest("GET", "/asset.bin", nil)
395+
rec := httptest.NewRecorder()
396+
app.HandlerFuncNew(rec, req)
397+
398+
if rec.Code != http.StatusOK {
399+
t.Fatalf("expected status 200, got %d", rec.Code)
400+
}
401+
if rec.Body.String() != "asset" {
402+
t.Fatalf("expected asset body, got %q", rec.Body.String())
403+
}
404+
}

0 commit comments

Comments
 (0)