refactor(web): make the web surface self-contained for embedding (refs #451)

Prepare soundtouch-web to be folded into soundtouch-service as an additive
mount. Two changes, no behaviour change for the standalone binary:

- Move the embedded assets from /static/* to /app/static/*, so the whole
  web UI lives under /api/control + /app and nothing contends with a host
  router's own /static (e.g. the optional Stockholm bridge's root catch-all).
  index.html and app.js asset references are updated in lockstep.
- Split Mount into a portable core and a standalone wrapper. MountWeb
  registers only the portable surface (/app/static/*, /api/control/*,
  /app/*) and nothing outside those subtrees (no /, no /health), so it can
  be mounted into another router additively. Mount (used by cmd/soundtouch-web)
  now calls MountWeb and adds the standalone-only /health and /->/app redirect.

mount_test.go exercises MountWeb (asserts the portable surface owns nothing
outside /api/control + /app) and Mount (asserts it adds / and /health).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-07 15:02:54 +02:00
co-authored by Claude Opus 4.8
parent 3a038b0129
commit 3693cfa65b
4 changed files with 102 additions and 73 deletions
+24 -13
View File
@@ -10,17 +10,19 @@ import (
"github.com/go-chi/chi/v5"
)
// Mount registers all routes (static, WebSocket, REST) on r. The
// discovery service is reused by the POST /api/discover handler to
// trigger an on-demand sweep — pass the same instance you used for
// startup discovery so settings (interface, timeout) stay consistent.
func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscoveryService) {
// Static assets (embedded in binary)
// MountWeb registers the portable soundtouch-web surface on r: the embedded
// assets (/app/static/*), the control API (/api/control/*), and the SPA
// (/app/*). It is self-contained under /api/control and /app, registering
// nothing outside those subtrees (no /, no /health), so it can be mounted into
// another router (e.g. soundtouch-service) additively. The discovery service
// is reused by POST /api/control/discover for on-demand sweeps; pass the same
// instance used for startup discovery so settings (interface, timeout) stay
// consistent.
func (app *WebApp) MountWeb(r chi.Router, discoveryService *discovery.UnifiedDiscoveryService) {
// Embedded assets, served under the /app subtree so nothing contends with a
// host router's own /static (e.g. the Stockholm bridge's root catch-all).
subFS, _ := fs.Sub(StaticFS, "static")
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
// Health / liveness
r.Get("/health", app.HandleHealth)
r.Get("/app/static/*", http.StripPrefix("/app/static", http.FileServer(http.FS(subFS))).ServeHTTP)
// Player / control API. Per #451 this is the post-merge canonical shape:
// device-scoped actions nest under devices/{id}/, so every direct child of
@@ -123,10 +125,19 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
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.
// Mount is the standalone soundtouch-web entry point: the portable web surface
// (see MountWeb) plus the standalone-only liveness endpoint and a / redirect
// into the app. soundtouch-service does not call this — it mounts MountWeb and
// keeps its own / (landing page) and /health.
func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscoveryService) {
app.MountWeb(r, discoveryService)
// Health / liveness (standalone only).
r.Get("/health", app.HandleHealth)
// Standalone convenience: the bare root jumps into the app.
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app", http.StatusFound)
})
+65 -47
View File
@@ -8,32 +8,42 @@ import (
"github.com/go-chi/chi/v5"
)
// TestMountControlAPIShape verifies the issue #451 web API restructure:
// building the router must not panic (catches any chi route-registration
// ambiguity), and every web `/api/*` route must live under `/api/control/*`
// (the post-merge canonical namespace). This is the only test that exercises
// Mount itself; the handler tests call handlers directly with injected params.
func TestMountControlAPIShape(t *testing.T) {
app := NewWebApp()
// walkRoutes returns the set of route patterns registered on r.
func walkRoutes(t *testing.T, r chi.Router) map[string]bool {
t.Helper()
r := chi.NewRouter()
app.Mount(r, nil) // must not panic while registering routes
routes := map[string]bool{}
var apiRoutes []string
registered := map[string]bool{}
walkErr := chi.Walk(r, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
registered[route] = true
if strings.HasPrefix(route, "/api/") {
apiRoutes = append(apiRoutes, route)
}
err := 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)
if err != nil {
t.Fatalf("walk routes: %v", err)
}
return routes
}
// TestMountWebControlAPIShape verifies the issue #451 web API restructure on
// the portable surface (MountWeb): building must not panic (catches any chi
// route-registration ambiguity), and every web /api/* route must live under
// /api/control/* (the post-merge canonical namespace).
func TestMountWebControlAPIShape(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.MountWeb(r, nil) // must not panic while registering routes
registered := walkRoutes(t, r)
var apiRoutes []string
for route := range registered {
if strings.HasPrefix(route, "/api/") {
apiRoutes = append(apiRoutes, route)
}
}
if len(apiRoutes) == 0 {
@@ -50,8 +60,10 @@ func TestMountControlAPIShape(t *testing.T) {
// The provider infix (#451): browsable providers expose global browse
// routes; every provider play nests under devices/{id}/providers/. The
// app-wide socket moved from top-level /ws to /api/control/ws.
// app-wide socket moved from top-level /ws to /api/control/ws, and assets
// live under /app/static.
mustExist := []string{
"/app/static/*",
"/api/control/version",
"/api/control/ws",
"/api/control/providers/tunein/search",
@@ -63,14 +75,18 @@ func TestMountControlAPIShape(t *testing.T) {
}
for _, want := range mustExist {
if !registered[want] {
t.Errorf("expected route %q to be registered; got %v", want, apiRoutes)
t.Errorf("expected route %q to be registered", want)
}
}
// The pre-infix flat paths are gone, and the app-wide socket no longer
// sits at top-level /ws.
// The pre-infix flat paths are gone, the app-wide socket no longer sits at
// top-level /ws, and the portable surface owns nothing outside
// /api/control + /app (no /, no /health, no top-level /static).
mustNotExist := []string{
"/",
"/ws",
"/health",
"/static/*",
"/api/control/tunein/search",
"/api/control/radiobrowser/search",
"/api/control/devices/{id}/play-url",
@@ -80,30 +96,21 @@ func TestMountControlAPIShape(t *testing.T) {
}
for _, gone := range mustNotExist {
if registered[gone] {
t.Errorf("pre-infix route %q should have moved under /providers/", gone)
t.Errorf("portable surface should not register %q", gone)
}
}
}
// 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) {
// TestMountWebSPARoutes verifies the issue #451 SPA move: the web UI is served
// under /app/* and the old top-level page paths are gone. The portable surface
// does not register /.
func TestMountWebSPARoutes(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.Mount(r, nil)
app.MountWeb(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)
}
routes := walkRoutes(t, r)
for _, want := range []string{"/app", "/app/devices", "/app/tunein"} {
if !routes[want] {
@@ -111,15 +118,26 @@ func TestMountSPARoutes(t *testing.T) {
}
}
// The old top-level page paths moved under /app.
for _, gone := range []string{"/devices", "/device/*", "/tunein", "/radiobrowser", "/playurl", "/tts"} {
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)
t.Errorf("top-level route %q should not be registered by the portable surface", gone)
}
}
}
// / stays registered, but only as the redirect into the app.
if !routes["/"] {
t.Error("expected / to remain registered (redirect into the app)")
// TestMountStandalone verifies that the standalone entry point (Mount) adds the
// / redirect and /health liveness endpoint on top of the portable surface.
func TestMountStandalone(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.Mount(r, nil)
routes := walkRoutes(t, r)
for _, want := range []string{"/", "/health", "/app", "/api/control/version"} {
if !routes[want] {
t.Errorf("expected standalone Mount to register %q", want)
}
}
}
+7 -7
View File
@@ -8,19 +8,19 @@
<script type="importmap">
{
"imports": {
"preact": "/static/lib/preact.module.js",
"preact/hooks": "/static/lib/preact-hooks.module.js",
"htm": "/static/lib/htm.module.js"
"preact": "/app/static/lib/preact.module.js",
"preact/hooks": "/app/static/lib/preact-hooks.module.js",
"htm": "/app/static/lib/htm.module.js"
}
}
</script>
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg" />
<link rel="alternate icon" href="/static/img/favicon.ico" />
<link rel="stylesheet" href="/static/css/app.css" />
<link rel="icon" type="image/svg+xml" href="/app/static/img/favicon.svg" />
<link rel="alternate icon" href="/app/static/img/favicon.ico" />
<link rel="stylesheet" href="/app/static/css/app.css" />
</head>
<body>
<div id="app"></div>
<p style="display:none">Bose SoundTouch Toolkit</p>
<script type="module" src="/static/js/app.js"></script>
<script type="module" src="/app/static/js/app.js"></script>
</body>
</html>
+6 -6
View File
@@ -152,7 +152,7 @@ function App() {
<div class="app">
<nav class="navbar">
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
<img src="/static/img/logo.svg" alt="AfterTouch" class="nav-logo" />
<img src="/app/static/img/logo.svg" alt="AfterTouch" class="nav-logo" />
<div class="brand-text">
<span class="brand-name">AfterTouch</span>
<span class="brand-subtitle">Bose SoundTouch Toolkit</span>
@@ -164,25 +164,25 @@ function App() {
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}
title="Devices"
>
<img src="/static/img/speaker-mono.svg" alt="Devices" class="nav-device-icon" />
<img src="/app/static/img/speaker-mono.svg" alt="Devices" class="nav-device-icon" />
</a>
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}
title="TuneIn"
>
<img src="/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
<img src="/app/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
</a>
<a href="#" class="${page === 'radiobrowser' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('radiobrowser'); }}
title="RadioBrowser"
>
<img src="/static/img/radiobrowser-mono.svg" alt="RadioBrowser" class="nav-rb-icon" />
<img src="/app/static/img/radiobrowser-mono.svg" alt="RadioBrowser" class="nav-rb-icon" />
</a>
<a href="#" class="${page === 'playurl' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('playurl'); }}
title="Play URL"
>
<img src="/static/img/link-mono.svg" alt="Play URL" class="nav-url-icon" />
<img src="/app/static/img/link-mono.svg" alt="Play URL" class="nav-url-icon" />
</a>
<a href="#" class="${page === 'tts' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('tts'); }}
@@ -196,7 +196,7 @@ function App() {
</a>
<span class="nav-separator">|</span>
<button class="btn-icon" onClick=${discover} title="Discover">
<img src="/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
<img src="/app/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
</button>
</div>
</nav>