mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
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:
co-authored by
Claude Opus 4.8
parent
3a038b0129
commit
3693cfa65b
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user