refactor: Frontend adapter for new WebSocket architecture
- websocket.js: addHandlers, _handlers Set, auth callback, envelope validation, exponential backoff, 50 MB cap - Three store components: namespace /webui → /ws, wire up addHandlers - Fix websocket-test-store.js detach) accidentally killing syncStore event listeners
keyboardstaff committed
Mar 26, 2026 at 01:02 UTC
af4d5db789584273a60dfc114a621335c1b6ee9b
4 files changed
+65
-8
webui/components/settings/developer/websocket-event-console-store.js
+3
-2
@@ -2,7 +2,8 @@ import { createStore } from "/js/AlpineStore.js";
2
import { getNamespacedClient } from "/js/websocket.js";
3
import { store as notificationStore } from "/components/notifications/notification-store.js";
4
5
-const websocket = getNamespacedClient("/dev_websocket_test");
5
+const websocket = getNamespacedClient("/ws");
6
+websocket.addHandlers(["ws_dev_test"]);
7
8
const DIAGNOSTIC_EVENT = "ws_dev_console_event";
9
const SUBSCRIBE_EVENT = "ws_event_console_subscribe";
@@ -205,7 +206,7 @@ const model = {
206
sid: payload.sid || null,
207
correlationId: payload.correlationId || envelope?.correlationId || null,
208
timestamp: payload.timestamp || envelope?.ts || new Date().toISOString(),
208
- handlerId: payload.handlerId || envelope?.handlerId || "WebSocketManager",
209
+ handlerId: payload.handlerId || envelope?.handlerId || "WsManager",
210
resultSummary: payload.resultSummary || {},
211
payloadSummary: payload.payloadSummary || {},
212
delivered: payload.delivered ?? null,
webui/components/settings/developer/websocket-test-store.js
+30
-3
@@ -11,8 +11,9 @@ import { store as syncStore } from "/components/sync/sync-store.js";
11
const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024;
12
const TOAST_DURATION = 5;
13
14
-const websocket = getNamespacedClient("/dev_websocket_test");
15
-const stateSocket = getNamespacedClient("/webui");
14
+const websocket = getNamespacedClient("/ws");
15
+websocket.addHandlers(["ws_dev_test"]);
16
+const stateSocket = websocket; // same /ws namespace client
17
18
function now() {
19
return new Date().toISOString();
@@ -163,7 +164,8 @@ const model = {
164
websocket.off("ws_tester_broadcast");
165
websocket.off("ws_tester_persistence");
166
websocket.off("ws_tester_broadcast_demo");
166
- stateSocket.off("state_push");
167
+ // NOTE: Do NOT blanket-remove state_push — that nukes syncStore's handler.
168
+ // The _subscriptionHandlers loop above already removes tester-specific handlers.
169
},
170
171
appendLog(message) {
@@ -720,6 +722,10 @@ const model = {
722
} finally {
723
stateSocket.request = originalRequest;
724
globalThis.poll = originalPoll;
725
+ // Clear dangling retry timers left by simulated failures.
726
+ syncStore._clearHandshakeRetry();
727
+ syncStore._handshakeRetryAttempt = 0;
728
+ syncStore._handshakeFailureCount = 0;
729
}
730
},
731
@@ -729,6 +735,10 @@ const model = {
735
return { ok: false, label, error: "syncStore._handlePush not available" };
736
}
737
const originalSendStateRequest = syncStore.sendStateRequest;
738
+ const savedMode = syncStore.mode;
739
+ const savedEpoch = syncStore.runtimeEpoch;
740
+ const savedSeq = syncStore.lastSeq;
741
+ const savedNeedsHandshake = syncStore.needsHandshake;
742
let calls = [];
743
try {
744
if (typeof originalSendStateRequest !== "function") {
@@ -773,6 +783,23 @@ const model = {
783
return { ok: false, label, error: error.message || error };
784
} finally {
785
syncStore.sendStateRequest = originalSendStateRequest;
786
+ // Restore syncStore state that the test mutated to avoid leaving the
787
+ // UI stuck in HANDSHAKE_PENDING after the suite finishes.
788
+ syncStore.runtimeEpoch = savedEpoch;
789
+ syncStore.lastSeq = savedSeq;
790
+ syncStore.needsHandshake = savedNeedsHandshake;
791
+ // Re-establish a real handshake to return to HEALTHY.
792
+ // Clear any dangling retry state before attempting recovery.
793
+ syncStore._clearHandshakeRetry();
794
+ syncStore._handshakeRetryAttempt = 0;
795
+ syncStore._handshakeFailureCount = 0;
796
+ try {
797
+ await syncStore.sendStateRequest({ forceFull: true });
798
+ } catch (_) {
799
+ // Best-effort recovery; let the normal retry mechanism take over
800
+ // instead of forcing HEALTHY with stale handshake state.
801
+ syncStore.needsHandshake = true;
802
+ }
803
}
804
},
805
webui/components/sync/sync-store.js
+2
-1
@@ -6,7 +6,8 @@ import { store as chatTopStore } from "/components/chat/top-section/chat-top-sto
6
import { store as notificationStore } from "/components/notifications/notification-store.js";
7
import * as Extensions from "/js/extensions.js"
8
9
-const stateSocket = getNamespacedClient("/webui");
9
+const stateSocket = getNamespacedClient("/ws");
10
+stateSocket.addHandlers(["ws_webui"]);
11
12
const SYNC_MODES = {
13
DISCONNECTED: "DISCONNECTED",
webui/js/websocket.js
+30
-2
@@ -274,6 +274,33 @@ class WebSocketClient {
274
this._csrfInvalidatedForConnectError = false;
275
this._connectErrorRetryTimer = null;
276
this._connectErrorRetryAttempt = 0;
277
+ this._handlers = new Set();
278
+ }
279
+
280
+ /**
281
+ * Declare WS handler paths to activate on connect (e.g. "ws_webui").
282
+ * Must be called before connect().
283
+ * @param {string[]} handlers
284
+ */
285
+ addHandlers(handlers) {
286
+ if (!Array.isArray(handlers)) return;
287
+ let changed = false;
288
+ for (const h of handlers) {
289
+ if (typeof h === "string" && h.trim()) {
290
+ const key = h.trim();
291
+ if (!this._handlers.has(key)) {
292
+ this._handlers.add(key);
293
+ changed = true;
294
+ }
295
+ }
296
+ }
297
+ // If new handlers were added while already connected, reconnect so the
298
+ // updated handler list is sent to the server via the auth callback.
299
+ if (changed && this.socket && this.socket.connected) {
300
+ this.debugLog("addHandlers: reconnecting to activate new handlers", [...this._handlers]);
301
+ this.socket.disconnect();
302
+ this.socket.connect();
303
+ }
304
}
305
306
_clearConnectErrorRetryTimer() {
@@ -594,11 +621,12 @@ class WebSocketClient {
621
transports: ["websocket", "polling"],
622
withCredentials: true,
623
auth: (cb) => {
624
+ const handlers = [...this._handlers];
625
getCsrfToken()
598
- .then((token) => cb({ csrf_token: token }))
626
+ .then((token) => cb({ csrf_token: token, handlers }))
627
.catch((error) => {
628
console.error("[websocket] failed to fetch CSRF token for connect", error);
601
- cb({});
629
+ cb({ handlers });
630
});
631
},
632
});