diff --git a/web/client/src/store/inventoryStore.js b/web/client/src/store/inventoryStore.js index 7bbc44c..184ab37 100644 --- a/web/client/src/store/inventoryStore.js +++ b/web/client/src/store/inventoryStore.js @@ -8,6 +8,29 @@ const API_URL = import.meta.env.VITE_API_URL || console.log('🔌 WebSocket URL:', WS_URL); console.log('📡 API URL:', API_URL); +// ========== Timers (module-level so they survive store re-creates) ========== +let _httpPollTimer = null; +let _wsHealthTimer = null; +let _lastWsMessage = 0; + +const HTTP_POLL_INTERVAL = 10_000; // Poll HTTP every 10 s as fallback +const WS_STALE_TIMEOUT = 35_000; // If no WS message for 35 s → reconnect + +function _applyStateData(data, current) { + return { + inventory: data.inventory || current.inventory, + activity: data.activity || current.activity, + alerts: data.alerts || current.alerts, + smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused, + disabledRecipes: data.disabledRecipes || current.disabledRecipes, + smeltable: data.smeltable || current.smeltable, + craftable: data.craftable || current.craftable, + craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk, + bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected, + lastUpdate: data.lastUpdate || Date.now(), + }; +} + export const useInventoryStore = create((set, get) => ({ // State inventory: { @@ -30,6 +53,7 @@ export const useInventoryStore = create((set, get) => ({ smeltable: {}, craftable: [], craftTurtleOk: false, + bridgeConnected: false, connected: false, ws: null, lastUpdate: 0, @@ -47,23 +71,35 @@ export const useInventoryStore = create((set, get) => ({ const current = get(); // Only apply if we have actual data with items if (data.inventory?.itemList?.length || !current.inventory.itemList?.length) { - set({ - inventory: data.inventory || current.inventory, - activity: data.activity || current.activity, - alerts: data.alerts || current.alerts, - smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused, - disabledRecipes: data.disabledRecipes || current.disabledRecipes, - smeltable: data.smeltable || current.smeltable, - craftable: data.craftable || current.craftable, - craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk, - lastUpdate: data.lastUpdate || current.lastUpdate, - }); + set(_applyStateData(data, current)); } } catch (error) { // Silent fail — WebSocket will provide data } }, + // Start periodic HTTP polling (runs alongside WS for redundancy) + _startHttpPoll: () => { + if (_httpPollTimer) return; + _httpPollTimer = setInterval(() => { + get().fetchState(); + }, HTTP_POLL_INTERVAL); + }, + + // Client-side WS health check — reconnect if no message received recently + _startWsHealthCheck: () => { + if (_wsHealthTimer) return; + _wsHealthTimer = setInterval(() => { + const ws = get().ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (_lastWsMessage > 0 && Date.now() - _lastWsMessage > WS_STALE_TIMEOUT) { + console.warn('⚠️ WebSocket stale (no messages for', Math.round((Date.now() - _lastWsMessage) / 1000), 's) — reconnecting'); + ws.close(); // triggers onclose → reconnect + _lastWsMessage = 0; // reset so we don't loop + } + }, 10_000); + }, + // WebSocket connection connect: () => { // Prevent duplicate connections @@ -75,30 +111,25 @@ export const useInventoryStore = create((set, get) => ({ // Fetch state via HTTP immediately (don't wait for WS) get().fetchState(); + // Ensure background timers are running + get()._startHttpPoll(); + get()._startWsHealthCheck(); + const ws = new WebSocket(WS_URL); ws.onopen = () => { console.log('✅ Connected to server'); + _lastWsMessage = Date.now(); set({ connected: true, ws }); }; ws.onmessage = (event) => { + _lastWsMessage = Date.now(); try { const data = JSON.parse(event.data); if (data.type === 'initial_state' || data.type === 'state_update') { - const current = get(); - set({ - inventory: data.inventory || current.inventory, - activity: data.activity || current.activity, - alerts: data.alerts || current.alerts, - smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused, - disabledRecipes: data.disabledRecipes || current.disabledRecipes, - smeltable: data.smeltable || current.smeltable, - craftable: data.craftable || current.craftable, - craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk, - lastUpdate: data.lastUpdate || Date.now(), - }); + set(_applyStateData(data, get())); } else if (data.type === 'command_result') { set({ commandResult: data }); // Clear after 5s