Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Canonical `GET /api/ws` listener WebSocket route. The existing `GET /ws`
remains as a compatibility alias that delegates to the same handler;
retirement of the alias is tracked in the native-API design plan. The
frontend now connects to `/api/ws`, and the Vite dev proxy covers both
paths.

### Changed

- Deployment guide reverse-proxy instructions now list `/api/ws` alongside
`/ws` and `/api/admin/ws` as paths that need WebSocket-upgrade forwarding.

## [1.1.2] — 2026-04-24

### Security
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/api/listener_ws_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package api_test

import (
"testing"
)

// TestListenerWSAlias verifies that both /ws (legacy compat alias) and /api/ws
// (canonical) are registered and point at the same handler.
func TestListenerWSAlias(t *testing.T) {
router, _ := newTestEngine(t)

var legacyHandler, canonicalHandler string
for _, rt := range router.Routes() {
if rt.Method != "GET" {
continue
}
switch rt.Path {
case "/ws":
legacyHandler = rt.Handler
case "/api/ws":
canonicalHandler = rt.Handler
}
}

if legacyHandler == "" {
t.Error("GET /ws route is not registered")
}
if canonicalHandler == "" {
t.Error("GET /api/ws route is not registered")
}
if legacyHandler != "" && canonicalHandler != "" && legacyHandler != canonicalHandler {
t.Errorf("listener WS handlers differ: /ws=%q /api/ws=%q",
legacyHandler, canonicalHandler)
}
}
7 changes: 6 additions & 1 deletion backend/internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,12 @@ func RegisterRoutes(r *gin.Engine, deps Deps) {
}

// WebSocket endpoints.
r.GET("/ws", gin.WrapF(ws.HandleListenerWS(deps.Hub, deps.Queries)))
// /api/ws is the canonical OpenScanner listener route. /ws is a temporary
// compatibility alias that delegates to the same handler so existing
// rdio-scanner-shaped clients keep working during the legacy-API transition.
listenerWS := gin.WrapF(ws.HandleListenerWS(deps.Hub, deps.Queries))
r.GET("/api/ws", listenerWS)
r.GET("/ws", listenerWS)
r.GET("/api/admin/ws", gin.WrapF(ws.HandleAdminWS(deps.Hub, deps.Queries)))

// Serve embedded frontend (SPA mode).
Expand Down
2 changes: 1 addition & 1 deletion docs/deployment-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Most people already have a web server (Caddy, nginx, Traefik) on their home serv

Two rules to remember when proxying:

- **Forward WebSocket upgrades** on `/ws` and `/api/admin/ws` — the live audio stream and admin events use them.
- **Forward WebSocket upgrades** on `/api/ws`, `/ws`, and `/api/admin/ws` — the live audio stream and admin events use them. `/api/ws` is the canonical listener endpoint; `/ws` is a compatibility alias kept for legacy clients and should also be proxied.
- **Send `X-Forwarded-Proto`** so OpenScanner knows whether to mark cookies as secure.

If the proxy is on the same machine, it's also a good idea to bind OpenScanner to localhost only so nothing bypasses the proxy. In your compose file:
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/services/wsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe("wsClient", () => {
wsClient.connect(store.dispatch);
expect(constructed).toHaveLength(1);
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
expect(constructed[0].url).toBe(`${proto}//${window.location.host}/ws`);
expect(constructed[0].url).toBe(`${proto}//${window.location.host}/api/ws`);
});

it("sends the auth token as a JSON array after onopen", () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/services/wsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class WsClient {
this.dispatch?.(setConnectionStatus("connecting"));

const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${window.location.host}/ws`;
const url = `${proto}//${window.location.host}/api/ws`;
this.ws = new WebSocket(url);

this.ws.onopen = () => {
Expand Down
1 change: 1 addition & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default defineConfig({
},
server: {
proxy: {
"/api/ws": { target: "ws://localhost:3000", ws: true },
"/api": "http://localhost:3000",
"/ws": { target: "ws://localhost:3000", ws: true },
},
Expand Down
Loading