From cd47ae0c5a34295f22a8dc3e83361df16181f765 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 6 Jun 2026 22:43:41 +0200 Subject: [PATCH] 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) --- pkg/service/soundtouchweb/mount.go | 27 ++++++++++++----- pkg/service/soundtouchweb/mount_test.go | 39 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index 7e0df27..faa5aab 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -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) { diff --git a/pkg/service/soundtouchweb/mount_test.go b/pkg/service/soundtouchweb/mount_test.go index 17e11b7..835a912 100644 --- a/pkg/service/soundtouchweb/mount_test.go +++ b/pkg/service/soundtouchweb/mount_test.go @@ -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)") + } +}