main
js 514 lines 17.1 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { getNamespacedClient } from "/js/websocket.js";
3 import { invalidateCsrfToken } from "/js/api.js";
4 import { applySnapshot, buildStateRequestPayload } from "/index.js";
5 import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
6 import { store as notificationStore } from "/components/notifications/notification-store.js";
7 import * as Extensions from "/js/extensions.js"
8
9 const stateSocket = getNamespacedClient("/ws");
10 stateSocket.addHandlers(["ws_webui"]);
11
12 const SYNC_MODES = {
13 DISCONNECTED: "DISCONNECTED",
14 HANDSHAKE_PENDING: "HANDSHAKE_PENDING",
15 HEALTHY: "HEALTHY",
16 DEGRADED: "DEGRADED",
17 };
18
19 function isDevelopmentRuntime() {
20 return Boolean(globalThis.runtimeInfo?.isDevelopment);
21 }
22
23 function isSyncDebugEnabled() {
24 try {
25 let value = globalThis.localStorage?.getItem("a0_debug_sync");
26 if (isDevelopmentRuntime()) {
27 globalThis.localStorage?.setItem("a0_debug_sync", "true");
28 value = "true";
29 }
30 return value === "true";
31 } catch (_error) {
32 return false;
33 }
34 }
35
36 function debug(...args) {
37 if (!isSyncDebugEnabled()) return;
38 // eslint-disable-next-line no-console
39 console.debug(...args);
40 }
41
42 function isRestartToastActive() {
43 return (
44 Array.isArray(notificationStore.toastStack) &&
45 notificationStore.toastStack.some((toast) => toast && toast.group === "restart")
46 );
47 }
48
49 const model = {
50 mode: SYNC_MODES.DISCONNECTED,
51 initialized: false,
52 needsHandshake: false,
53 handshakePromise: null,
54 _pushQueue: Promise.resolve(),
55 _handshakeQueued: false,
56 _queuedPayload: null,
57 _inFlightPayload: null,
58 _seenFirstConnect: false,
59 _lastConnectWasFirst: true,
60 _pendingReconnectToast: null,
61 _wasDegraded: false,
62 _degradedToastShown: false,
63 _degradedToastTimer: null,
64 _degradedToastDelayMs: 100,
65 _handshakeRetryTimer: null,
66 _handshakeRetryAttempt: 0,
67 _handshakeRetryBaseMs: 500,
68 _handshakeRetryCapMs: 5000,
69 _handshakeFailureCount: 0,
70 _forceReconnectCooldownMs: 5000,
71 _lastForceReconnectAtMs: 0,
72 _forceReconnectThreshold: 3,
73 _suppressDisconnectToastOnce: false,
74
75 runtimeEpoch: null,
76 seqBase: 0,
77 lastSeq: 0,
78
79 _setMode(newMode, reason = "") {
80 const oldMode = this.mode;
81 if (oldMode === newMode) return;
82 this.mode = newMode;
83 debug("[syncStore] Mode transition:", oldMode, "", newMode, reason ? `(${reason})` : "");
84
85 if (newMode !== SYNC_MODES.DEGRADED) {
86 if (this._degradedToastTimer) {
87 clearTimeout(this._degradedToastTimer);
88 this._degradedToastTimer = null;
89 }
90 }
91
92 if (newMode === SYNC_MODES.DISCONNECTED) {
93 this._wasDegraded = false;
94 this._degradedToastShown = false;
95 }
96
97 if (newMode === SYNC_MODES.DEGRADED) {
98 this._wasDegraded = true;
99 if (this._degradedToastShown || this._degradedToastTimer) {
100 return;
101 }
102 this._degradedToastTimer = setTimeout(() => {
103 this._degradedToastTimer = null;
104 this._degradedToastShown = true;
105 notificationStore
106 .frontendWarning(
107 "WebSocket connection problems - using polling fallback",
108 "Connection",
109 5,
110 "sync-mode",
111 undefined,
112 true
113 )
114 .catch((error) => {
115 console.error("[syncStore] degraded toast failed:", error);
116 });
117 }, this._degradedToastDelayMs);
118 return;
119 }
120
121 if (newMode === SYNC_MODES.HEALTHY) {
122 if (this._degradedToastShown) {
123 notificationStore
124 .frontendSuccess(
125 "WebSocket connection restored",
126 "Connection",
127 4,
128 "sync-mode",
129 undefined,
130 true
131 )
132 .catch((error) => {
133 console.error("[syncStore] recovery toast failed:", error);
134 });
135 }
136 this._wasDegraded = false;
137 this._degradedToastShown = false;
138 }
139 },
140
141 _clearHandshakeRetry() {
142 if (this._handshakeRetryTimer) {
143 clearTimeout(this._handshakeRetryTimer);
144 this._handshakeRetryTimer = null;
145 }
146 },
147
148 _scheduleHandshakeRetry(reason, forceReconnect = false) {
149 if (this._handshakeRetryTimer) return;
150 if (!this.needsHandshake) return;
151 if (!stateSocket.isConnected()) return;
152
153 const attempt = Math.max(0, Number(this._handshakeRetryAttempt) || 0);
154 const delayMs = Math.min(this._handshakeRetryCapMs, this._handshakeRetryBaseMs * 2 ** attempt);
155 this._handshakeRetryAttempt = attempt + 1;
156
157 debug("[syncStore] scheduling handshake retry", {
158 reason,
159 attempt,
160 delayMs,
161 forceReconnect,
162 });
163 this._handshakeRetryTimer = setTimeout(() => {
164 this._handshakeRetryTimer = null;
165 if (!stateSocket.isConnected()) return;
166 if (!this.needsHandshake) return;
167 if (forceReconnect) {
168 this._forceReconnect(reason);
169 return;
170 }
171 this.sendStateRequest({ forceFull: true }).catch((error) => {
172 console.error("[syncStore] handshake retry failed:", error);
173 });
174 }, delayMs);
175 },
176
177 _handleHandshakeFailure(reason) {
178 this._handshakeFailureCount += 1;
179 debug("[syncStore] handshake failure tracked", {
180 reason,
181 count: this._handshakeFailureCount,
182 threshold: this._forceReconnectThreshold,
183 });
184 if (this._handshakeFailureCount < this._forceReconnectThreshold) {
185 this._scheduleHandshakeRetry(reason, false);
186 return;
187 }
188 this._handshakeFailureCount = 0;
189 this._scheduleHandshakeRetry(reason, true);
190 },
191
192 _forceReconnect(reason) {
193 const now = Date.now();
194 if (now - this._lastForceReconnectAtMs < this._forceReconnectCooldownMs) {
195 return;
196 }
197 this._lastForceReconnectAtMs = now;
198 this._suppressDisconnectToastOnce = true;
199 debug("[syncStore] forcing socket reconnect", { reason });
200 try {
201 invalidateCsrfToken();
202 } catch (_error) {
203 // no-op
204 }
205 try {
206 stateSocket.disconnect();
207 } catch (error) {
208 console.error("[syncStore] forced disconnect failed:", error);
209 }
210 this.needsHandshake = true;
211 this._clearHandshakeRetry();
212 this._handshakeRetryAttempt = 0;
213 stateSocket.connect().catch((error) => {
214 console.error("[syncStore] forced reconnect failed:", error);
215 });
216 },
217
218 async _flushPendingReconnectToast() {
219 const pending = this._pendingReconnectToast;
220 if (!pending) return;
221 this._pendingReconnectToast = null;
222
223 try {
224 if (pending === "restart") {
225 await notificationStore.frontendSuccess(
226 "Restarted",
227 "System Restart",
228 5,
229 "restart",
230 undefined,
231 true,
232 );
233 return;
234 }
235 await notificationStore.frontendSuccess(
236 "Reconnected",
237 "Connection",
238 3,
239 "reconnect",
240 undefined,
241 true,
242 );
243 } catch (error) {
244 console.error("[syncStore] reconnect toast failed:", error);
245 }
246 },
247
248 async init() {
249 if (this.initialized) return;
250 this.initialized = true;
251
252 try {
253 stateSocket.onConnect((info) => {
254 chatTopStore.connected = true;
255 debug("[syncStore] websocket connected", { needsHandshake: this.needsHandshake });
256
257 const firstConnect = Boolean(info && info.firstConnect);
258 this._lastConnectWasFirst = firstConnect;
259 if (firstConnect) {
260 this._seenFirstConnect = true;
261 } else if (this._seenFirstConnect) {
262 const runtimeChanged = Boolean(info && info.runtimeChanged);
263 this._pendingReconnectToast = runtimeChanged ? "restart" : "reconnect";
264 }
265 this._clearHandshakeRetry();
266 this._handshakeRetryAttempt = 0;
267
268 // Always re-handshake on every Socket.IO connect.
269 //
270 // The backend StateMonitor tracking is per-sid and starts with seq_base=0 on a
271 // newly connected sid. If a tab misses the 'disconnect' event (e.g. browser
272 // suspended overnight) it can look HEALTHY locally while never sending a
273 // fresh state_request, so pushes are gated and logs appear to stall.
274 this.sendStateRequest({ forceFull: true }).catch((error) => {
275 console.error("[syncStore] connect handshake failed:", error);
276 });
277 });
278
279 stateSocket.onDisconnect(() => {
280 chatTopStore.connected = false;
281 const restartToastActive = isRestartToastActive();
282 this._setMode(
283 SYNC_MODES.DISCONNECTED,
284 restartToastActive ? "ws disconnect (restart toast active)" : "ws disconnect",
285 );
286 this.needsHandshake = true;
287 this._clearHandshakeRetry();
288 debug("[syncStore] websocket disconnected");
289
290 // Tab-local UX: brief "Disconnected" toast. This intentionally does not go through
291 // the backend notification pipeline (no cross-tab intent, avoids request storms).
292 // Uses the same group as "Reconnected" so the reconnect toast replaces it if still visible.
293 const suppressToast = this._suppressDisconnectToastOnce;
294 this._suppressDisconnectToastOnce = false;
295 if (this._seenFirstConnect && !restartToastActive && !suppressToast) {
296 notificationStore
297 .frontendWarning("Disconnected", "Connection", 5, "reconnect", undefined, true)
298 .catch((error) => {
299 console.error("[syncStore] disconnected toast failed:", error);
300 });
301 }
302 });
303
304 await stateSocket.on("state_push", (envelope) => {
305 this._pushQueue = this._pushQueue
306 .then(() => this._handlePush(envelope))
307 .catch((error) => {
308 console.error("[syncStore] state_push handler failed:", error);
309 });
310 });
311 debug("[syncStore] subscribed to state_push");
312
313 await stateSocket.on("server_restart", (envelope) => {
314 // Avoid showing restart toast on the initial connect; prefer reconnect flows.
315 if (this._lastConnectWasFirst) return;
316 const runtimeId = envelope?.data?.runtimeId || null;
317 debug("[syncStore] server_restart received", { runtimeId });
318 this._pendingReconnectToast = "restart";
319 });
320 debug("[syncStore] subscribed to server_restart");
321
322 // handle all requests with extensions
323 await stateSocket.on("*", (eventType, envelope) => {
324 // console.log(`[syncStore] *${eventType} received`);
325 this.handleEvent(eventType, envelope)
326 });
327
328 await this.sendStateRequest({ forceFull: true });
329 } catch (error) {
330 console.error("[syncStore] init failed:", error);
331 // Initialization failures often mean the socket can't connect; treat as disconnected.
332 this._setMode(SYNC_MODES.DISCONNECTED, "init failed");
333 }
334 },
335
336 async handleEvent(eventType, envelope){
337 await Extensions.callJsExtensions("webui_ws_push", eventType, envelope);
338 },
339
340 async sendStateRequest(options = {}) {
341 const { forceFull = false } = options || {};
342 const payload = buildStateRequestPayload({ forceFull });
343 return await this._sendStateRequestPayload(payload);
344 },
345
346 async _sendStateRequestPayload(payload) {
347 if (this.handshakePromise) {
348 const inFlight = this._inFlightPayload;
349 if (
350 inFlight &&
351 payload &&
352 payload.context === inFlight.context &&
353 typeof payload.log_from === "number" &&
354 typeof payload.notifications_from === "number" &&
355 typeof inFlight.log_from === "number" &&
356 typeof inFlight.notifications_from === "number"
357 ) {
358 const stronger =
359 payload.log_from <= inFlight.log_from &&
360 payload.notifications_from <= inFlight.notifications_from &&
361 (payload.log_from < inFlight.log_from ||
362 payload.notifications_from < inFlight.notifications_from);
363 if (!stronger) {
364 debug("[syncStore] state_request ignored (in-flight stronger/equal)", payload);
365 return await this.handshakePromise;
366 }
367 }
368
369 // Coalesce repeated requests while a handshake is in-flight. This is important
370 // for fast context switching and resync flows where multiple requests can happen
371 // back-to-back with different contexts/offsets.
372 this._handshakeQueued = true;
373 const queued = this._queuedPayload;
374 if (!queued || !payload || payload.context !== queued.context) {
375 this._queuedPayload = payload;
376 } else if (
377 typeof payload.log_from === "number" &&
378 typeof payload.notifications_from === "number" &&
379 typeof queued.log_from === "number" &&
380 typeof queued.notifications_from === "number"
381 ) {
382 // Keep the "strongest" request: smaller offsets (0) mean a more complete resync.
383 const queuedStrongerOrEqual =
384 queued.log_from <= payload.log_from && queued.notifications_from <= payload.notifications_from;
385 if (!queuedStrongerOrEqual) {
386 this._queuedPayload = payload;
387 }
388 }
389 debug("[syncStore] state_request coalesced (handshake in-flight)", payload);
390 return await this.handshakePromise;
391 }
392
393 this._inFlightPayload = payload;
394 this.handshakePromise = (async () => {
395 this._setMode(SYNC_MODES.HANDSHAKE_PENDING, "sendStateRequest");
396
397 let response;
398 try {
399 debug("[syncStore] state_request sent", payload);
400 response = await stateSocket.request("state_request", payload, { timeoutMs: 2000 });
401 } catch (error) {
402 this.needsHandshake = true;
403 // If the socket isn't connected, we are disconnected (poll may or may not work).
404 // If the socket is connected but the request failed/timed out, treat as degraded (poll fallback).
405 this._setMode(
406 stateSocket.isConnected() ? SYNC_MODES.DEGRADED : SYNC_MODES.DISCONNECTED,
407 "state_request failed",
408 );
409 this._handleHandshakeFailure("state_request failed");
410 throw error;
411 }
412
413 const first = response && Array.isArray(response.results) ? response.results[0] : null;
414 if (!first || first.ok !== true || !first.data) {
415 const code =
416 first && first.error && typeof first.error.code === "string"
417 ? first.error.code
418 : "HANDSHAKE_FAILED";
419 this._setMode(SYNC_MODES.DEGRADED, `handshake failed: ${code}`);
420 this.needsHandshake = true;
421 this._handleHandshakeFailure(`handshake failed: ${code}`);
422 throw new Error(`state_request failed: ${code}`);
423 }
424
425 const data = first.data;
426 if (typeof data.runtime_epoch === "string") {
427 this.runtimeEpoch = data.runtime_epoch;
428 }
429 if (typeof data.seq_base === "number" && Number.isFinite(data.seq_base)) {
430 this.seqBase = data.seq_base;
431 this.lastSeq = data.seq_base;
432 }
433
434 this.needsHandshake = false;
435 this._handshakeFailureCount = 0;
436 this._clearHandshakeRetry();
437 this._handshakeRetryAttempt = 0;
438 this._setMode(SYNC_MODES.HEALTHY, "handshake ok");
439 })().finally(() => {
440 this.handshakePromise = null;
441 this._inFlightPayload = null;
442
443 if (this._handshakeQueued) {
444 const queuedPayload = this._queuedPayload;
445 this._handshakeQueued = false;
446 this._queuedPayload = null;
447 if (queuedPayload) {
448 debug("[syncStore] sending queued state_request", queuedPayload);
449 Promise.resolve().then(() => {
450 this._sendStateRequestPayload(queuedPayload).catch((error) => {
451 console.error("[syncStore] queued state_request failed:", error);
452 });
453 });
454 }
455 }
456 });
457
458 return await this.handshakePromise;
459 },
460
461 async _handlePush(envelope) {
462 if (this.mode === SYNC_MODES.DEGRADED) {
463 debug("[syncStore] ignoring state_push while DEGRADED");
464 return;
465 }
466
467 const data = envelope && envelope.data ? envelope.data : null;
468 if (!data || typeof data !== "object") return;
469
470 if (typeof data.runtime_epoch === "string") {
471 if (this.runtimeEpoch && this.runtimeEpoch !== data.runtime_epoch) {
472 debug("[syncStore] runtime_epoch mismatch -> resync", {
473 current: this.runtimeEpoch,
474 incoming: data.runtime_epoch,
475 });
476 this._setMode(SYNC_MODES.HANDSHAKE_PENDING, "runtime_epoch mismatch");
477 await this.sendStateRequest({ forceFull: true });
478 return;
479 }
480 this.runtimeEpoch = data.runtime_epoch;
481 }
482
483 if (typeof data.seq === "number" && Number.isFinite(data.seq)) {
484 const expected = this.lastSeq + 1;
485 if (this.lastSeq > 0 && data.seq !== expected) {
486 debug("[syncStore] seq gap/out-of-order -> resync", {
487 lastSeq: this.lastSeq,
488 expected,
489 incoming: data.seq,
490 });
491 this._setMode(SYNC_MODES.HANDSHAKE_PENDING, "seq gap");
492 await this.sendStateRequest({ forceFull: true });
493 return;
494 }
495 this.lastSeq = data.seq;
496 }
497
498 if (data.snapshot && typeof data.snapshot === "object") {
499 await applySnapshot(data.snapshot, {
500 onLogGuidReset: async () => {
501 debug("[syncStore] log_guid reset -> resync (forceFull)");
502 await this.sendStateRequest({ forceFull: true });
503 },
504 });
505 if (!stateSocket.isConnected()) return;
506 this._setMode(SYNC_MODES.HEALTHY, "push applied");
507 await this._flushPendingReconnectToast();
508 }
509 },
510 };
511
512 const store = createStore("sync", model);
513
514 export { store, SYNC_MODES };