refactor(web): serve the SPA under /app/* (refs #451)

Move the soundtouch-web single-page app from top-level page paths
(/devices, /tunein, ...) under one /app subtree, so the whole web UI
lives under /app/* and folding -web into -service stays an additive
mount. The client navigates via component state rather than the URL and
all assets are referenced absolutely (/static/...), so this is a pure
routing change: no frontend edits needed.

The bare root / now redirects into the app (standalone convenience).
When -web is folded into -service, / instead serves a landing page
(admin vs app) and this redirect is replaced.

Extend mount_test.go with TestMountSPARoutes: the SPA resolves under
/app, the old top-level page paths are gone, and / remains only as the
redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-06 23:05:59 +02:00
co-authored by Claude Opus 4.8
parent a16b4babcb
commit cd47ae0c5a
2 changed files with 58 additions and 8 deletions
+19 -8
View File
@@ -99,14 +99,25 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
})
})
// SPA routes — serve index.html for client-side routing
r.Get("/", app.serveIndex)
r.Get("/devices", app.serveIndex)
r.Get("/device/*", app.serveIndex)
r.Get("/tunein", app.serveIndex)
r.Get("/radiobrowser", app.serveIndex)
r.Get("/playurl", app.serveIndex)
r.Get("/tts", app.serveIndex)
// SPA — served under /app/*. The client navigates via component state
// rather than the URL, so these entries only ensure deep links and
// refreshes return index.html instead of 404. Per #451 this keeps the
// whole web UI under one /app subtree, so folding -web into -service is an
// additive mount.
r.Get("/app", app.serveIndex)
r.Get("/app/devices", app.serveIndex)
r.Get("/app/device/*", app.serveIndex)
r.Get("/app/tunein", app.serveIndex)
r.Get("/app/radiobrowser", app.serveIndex)
r.Get("/app/playurl", app.serveIndex)
r.Get("/app/tts", app.serveIndex)
// Standalone convenience: the bare root jumps into the app. When -web is
// folded into -service, / instead serves a landing page (admin vs app) and
// this redirect is replaced.
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app", http.StatusFound)
})
}
func (app *WebApp) serveIndex(w http.ResponseWriter, _ *http.Request) {
+39
View File
@@ -59,3 +59,42 @@ func TestMountControlAPIShape(t *testing.T) {
t.Errorf("expected /api/control/version to be registered; got %v", apiRoutes)
}
}
// TestMountSPARoutes verifies the issue #451 SPA move: the web UI is served
// under /app/* and the old top-level page paths are gone, with / kept only as
// a redirect into the app.
func TestMountSPARoutes(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.Mount(r, nil)
routes := map[string]bool{}
walkErr := chi.Walk(r, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
routes[route] = true
return nil
})
if walkErr != nil {
t.Fatalf("walk routes: %v", walkErr)
}
for _, want := range []string{"/app", "/app/devices", "/app/tunein"} {
if !routes[want] {
t.Errorf("expected SPA route %q under /app to be registered", want)
}
}
// The old top-level page paths moved under /app.
for _, gone := range []string{"/devices", "/device/*", "/tunein", "/radiobrowser", "/playurl", "/tts"} {
if routes[gone] {
t.Errorf("top-level SPA route %q should have moved under /app", gone)
}
}
// / stays registered, but only as the redirect into the app.
if !routes["/"] {
t.Error("expected / to remain registered (redirect into the app)")
}
}