refactor(web): move the app-wide socket to /api/control/ws (refs #451)

Move the web UI's app-wide event stream (device list, discovery status,
per-device status updates) from top-level /ws to /api/control/ws. It is
the read/event half of the control surface, so it belongs under the same
namespace as the rest of the web API (the per-device socket already sits
at /api/control/devices/{id}/ws). The bundled app.js WebSocket URL is
updated in lockstep.

This brings soundtouch-web's entire HTTP surface under two clean subtrees
(/api/control/* for the API, /app/* for the SPA), so folding -web into
-service becomes a near-additive mount.

mount_test.go now asserts /api/control/ws is registered and top-level /ws
is gone.

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 d9581dc10f
commit 3a038b0129
4 changed files with 18 additions and 13 deletions
+1 -1
View File
@@ -498,7 +498,7 @@ func TestHandleWebSocket_InvalidUpgrade(t *testing.T) {
app := createTestApp()
// Test without proper WebSocket headers (should fail gracefully)
req := httptest.NewRequest("GET", "/ws", nil)
req := httptest.NewRequest("GET", "/api/control/ws", nil)
w := httptest.NewRecorder()
// This will fail because it's not a real WebSocket upgrade, but should not panic
+6 -4
View File
@@ -19,20 +19,22 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
subFS, _ := fs.Sub(StaticFS, "static")
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
// WebSocket endpoint
r.Get("/ws", app.HandleWebSocket)
// Health / liveness
r.Get("/health", app.HandleHealth)
// Player / control API. Per #451 this is the post-merge canonical shape:
// device-scoped actions nest under devices/{id}/, so every direct child of
// /api/control is a literal namespace (version, discover, devices,
// /api/control is a literal namespace (version, ws, discover, devices,
// providers) — no static-vs-param sibling, so routing never depends on
// chi's static-over-param precedence.
r.Route("/api/control", func(r chi.Router) {
r.Get("/version", app.HandleAPIVersion)
// App-wide event stream: device list, discovery status, per-device
// status updates. The read/event half of the control surface (the
// per-device socket lives at devices/{id}/ws).
r.Get("/ws", app.HandleWebSocket)
r.Post("/discover", func(w http.ResponseWriter, r *http.Request) {
app.HandleAPIDiscover(w, r)
+10 -7
View File
@@ -21,7 +21,11 @@ func TestMountControlAPIShape(t *testing.T) {
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)
}
@@ -44,15 +48,12 @@ func TestMountControlAPIShape(t *testing.T) {
}
}
registered := make(map[string]bool, len(apiRoutes))
for _, route := range apiRoutes {
registered[route] = true
}
// The provider infix (#451): browsable providers expose global browse
// routes; every provider play nests under devices/{id}/providers/.
// routes; every provider play nests under devices/{id}/providers/. The
// app-wide socket moved from top-level /ws to /api/control/ws.
mustExist := []string{
"/api/control/version",
"/api/control/ws",
"/api/control/providers/tunein/search",
"/api/control/providers/radiobrowser/search",
"/api/control/devices/{id}/providers/tunein/play",
@@ -66,8 +67,10 @@ func TestMountControlAPIShape(t *testing.T) {
}
}
// The pre-infix flat paths are gone.
// The pre-infix flat paths are gone, and the app-wide socket no longer
// sits at top-level /ws.
mustNotExist := []string{
"/ws",
"/api/control/tunein/search",
"/api/control/radiobrowser/search",
"/api/control/devices/{id}/play-url",
+1 -1
View File
@@ -91,7 +91,7 @@ function App() {
.catch(err => console.error('Failed to fetch version:', err));
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
const ws = new WebSocket(`${protocol}//${location.host}/api/control/ws`);
let reconnectTimer;
ws.onmessage = (event) => {