From 11a398ce89eaa224a6a9b3c8dc4d79fa0c48628a Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Sat, 15 Aug 2026 01:41:09 +0000 Subject: [PATCH] ws: decouple reconnection from auth internals, use model --- webui/static/hoover/websocket.js | 65 ++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 31ac0de..4b9da0c 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -4,15 +4,21 @@ * WebSocket connection manager with auto-reconnect. WS messages are routed * to model-based refresh and direct onMessage handlers. * Page-level subscribe/unsubscribe is replaced by the model layer. + * + * The JWT is read from the auth model (single source of truth). After 3 + * failed close attempts a token refresh is triggered through the auth + * model; the reconnect decision branches on the model's token state — + * never on the refresh promise. Terminal (no-token) transitions are + * handled by the auth model's onSuccess (clears storage, redirects, + * dispatches auth:logout). */ import { refreshByTopic } from './model.js'; -import { tryRefreshToken, redirectLogin } from './api.js'; +import { refreshAuth, getAuthToken } from './auth_model.js'; let _wsConn = null; let _wsReconnectMs = 0; let _wsFailCount = 0; -let _wsRefreshing = false; let _wsClosingHandled = false; /** Direct onMessage handlers — { topics, handler, unsubscribed }[] */ @@ -32,50 +38,43 @@ function _wsUrl() { /** Attempt a WebSocket connection. * Passes the JWT in the WebSocket subprotocol header (Sec-WebSocket-Protocol) * instead of a query parameter, keeping it out of logs and browser history. + * No token: no socket is created — the daemon 401s unauthenticated WS + * connections and connect() only runs while authenticated. */ function _wsConnect() { if (_wsConn && _wsConn.readyState <= 1) return; _wsClosingHandled = false; - const token = window.__auth_token__; - if (token) { - _wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]); - } else { - _wsConn = new WebSocket(_wsUrl()); - } + const token = getAuthToken(); + if (!token) return; + + _wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]); _wsConn.onopen = () => { _wsReconnectMs = 0; _wsFailCount = 0; - _wsRefreshing = false; _wsClosingHandled = false; }; - _wsConn.onclose = () => { + _wsConn.onclose = async () => { if (_wsClosingHandled) return; _wsClosingHandled = true; - if (!window.__auth_token__) return; + if (!getAuthToken()) return; _wsFailCount++; - if (_wsFailCount >= 3 && !_wsRefreshing) { - _wsRefreshing = true; + if (_wsFailCount >= 3) { const oldConn = _wsConn; - tryRefreshToken().then(ok => { - _wsRefreshing = false; - if (ok) { - _wsFailCount = 0; - _wsReconnectMs = 0; - _wsConn = null; - if (oldConn) oldConn.close(); - setTimeout(_wsConnect, 100); - } else { - redirectLogin(); - } - }).catch(() => { - _wsRefreshing = false; - _wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000); - setTimeout(_wsConnect, _wsReconnectMs); - }); + _wsFailCount = 0; + await refreshAuth(); // never rejects; failure path handled by model onSuccess + if (getAuthToken()) { + _wsReconnectMs = 0; + _wsConn = null; + if (oldConn) oldConn.close(); + setTimeout(_wsConnect, 100); + } + // No token after the refresh: onSuccess already cleared storage + // and redirected to #/login; the no-token guard at the top of + // onclose stops further reconnect attempts. return; } @@ -153,3 +152,11 @@ export function onMessage(topics, handler) { export function connect() { _wsConnect(); } + +/** Close the WS socket (terminal auth transition). */ +export function disconnect() { + if (_wsConn) { + _wsConn.close(); + _wsConn = null; + } +}