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 });