import { create } from 'zustand'; const _baseWsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`; const API_URL = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`; const API_KEY = import.meta.env.VITE_API_KEY || ''; // Append API key to WebSocket URL as query param const WS_URL = API_KEY ? `${_baseWsUrl}?key=${encodeURIComponent(API_KEY)}` : _baseWsUrl; // Build common headers for authenticated requests function authHeaders(extra = {}) { const headers = { 'Content-Type': 'application/json', ...extra }; if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`; return headers; } console.log('🔌 WebSocket URL:', _baseWsUrl); console.log('📡 API URL:', API_URL); if (API_KEY) console.log('🔒 API key configured'); // Generate a unique command ID for idempotent requests function newCommandId() { return crypto.randomUUID(); } // ========== Timers (module-level so they survive store re-creates) ========== let _httpPollTimer = null; let _wsHealthTimer = null; let _lastWsMessage = 0; let _reconnectDelay = 1000; 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, dropperNicknames: data.dropperNicknames || current.dropperNicknames, lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(), }; } export const useInventoryStore = create((set, get) => ({ // State inventory: { itemList: [], grandTotal: 0, chestCount: 0, totalSlots: 0, usedSlots: 0, freeSlots: 0, usedRatio: 0, dropperOk: false, barrelOk: false, furnaceCount: 0, furnaceStatus: {}, droppers: [], }, activity: {}, alerts: [], smeltingPaused: false, disabledRecipes: {}, smeltable: {}, craftable: [], craftTurtleOk: false, bridgeConnected: false, dropperNicknames: {}, connected: false, ws: null, lastUpdate: 0, searchQuery: '', commandResult: null, historySummary: [], itemHistory: {}, // Fetch state via HTTP API (fallback / initial load) fetchState: async () => { try { const response = await fetch(`${API_URL}/inventory`); if (!response.ok) return; const data = await response.json(); const current = get(); // Only apply if we have actual data with items if (data.inventory?.itemList?.length || !current.inventory.itemList?.length) { 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 const existing = get().ws; if (existing && (existing.readyState === WebSocket.CONNECTING || existing.readyState === WebSocket.OPEN)) { return; } // 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(); _reconnectDelay = 1000; 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') { set(_applyStateData(data, get())); } else if (data.type === 'command_result') { set({ commandResult: data }); // Clear after 5s setTimeout(() => { set((state) => { if (state.commandResult === data) { return { commandResult: null }; } return {}; }); }, 5000); } } catch (error) { console.error('Error processing message:', error); } }; ws.onclose = () => { console.log('❌ Disconnected from server'); set({ connected: false, ws: null }); const delay = _reconnectDelay; _reconnectDelay = Math.min(_reconnectDelay * 2, 30000); setTimeout(() => { get().connect(); }, delay); }; ws.onerror = (error) => { console.error('WebSocket error:', error); }; }, // Search setSearchQuery: (query) => set({ searchQuery: query }), getFilteredItems: () => { const { inventory, searchQuery } = get(); const items = inventory.itemList || []; if (!searchQuery) return items; const q = searchQuery.toLowerCase(); return items.filter((item) => { const name = (item.displayName || item.name || '').toLowerCase(); return name.includes(q); }); }, // Actions via REST API orderItem: async (itemName, amount, dropperName) => { try { const response = await fetch(`${API_URL}/order`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ itemName, amount, dropperName, commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error ordering item:', error); return { success: false, error: error.message }; } }, requestScan: async () => { try { const response = await fetch(`${API_URL}/scan`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error requesting scan:', error); return { success: false, error: error.message }; } }, toggleSmelting: async () => { try { const response = await fetch(`${API_URL}/smelting/toggle`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error toggling smelting:', error); return { success: false, error: error.message }; } }, toggleRecipe: async (recipe) => { try { const response = await fetch(`${API_URL}/recipes/toggle`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ recipe, commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error toggling recipe:', error); return { success: false, error: error.message }; } }, enableAllRecipes: async () => { try { const response = await fetch(`${API_URL}/recipes/enable-all`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error enabling all recipes:', error); return { success: false, error: error.message }; } }, disableAllRecipes: async () => { try { const response = await fetch(`${API_URL}/recipes/disable-all`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error disabling all recipes:', error); return { success: false, error: error.message }; } }, craftItem: async (recipeIdx) => { try { const response = await fetch(`${API_URL}/craft`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ recipeIdx, commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error crafting item:', error); return { success: false, error: error.message }; } }, sortBarrel: async (barrelName) => { try { const response = await fetch(`${API_URL}/sort-barrel`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ barrelName, commandId: newCommandId() }), }); return await response.json(); } catch (error) { console.error('❌ Error sorting barrel:', error); return { success: false, error: error.message }; } }, // Dropper nicknames setDropperNickname: async (dropperName, nickname) => { try { const response = await fetch(`${API_URL}/dropper-nicknames`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }), }); const result = await response.json(); if (result.nicknames) { set({ dropperNicknames: result.nicknames }); } return result; } catch (error) { console.error('❌ Error setting dropper nickname:', error); return { success: false, error: error.message }; } }, fetchDropperNicknames: async () => { try { const response = await fetch(`${API_URL}/dropper-nicknames`); if (!response.ok) return; const data = await response.json(); set({ dropperNicknames: data.nicknames || {} }); } catch (error) { console.error('❌ Error fetching dropper nicknames:', error); } }, // Analytics - fetch aggregate storage history fetchHistorySummary: async () => { try { const response = await fetch(`${API_URL}/history-summary`); if (!response.ok) return; const data = await response.json(); set({ historySummary: data.history || [] }); } catch (error) { console.error('❌ Error fetching history summary:', error); } }, // Analytics - fetch single item history fetchItemHistory: async (itemName) => { try { const response = await fetch(`${API_URL}/history/${encodeURIComponent(itemName)}?limit=200`); if (!response.ok) return; const data = await response.json(); set((state) => ({ itemHistory: { ...state.itemHistory, [itemName]: data.history || [] }, })); } catch (error) { console.error('❌ Error fetching item history:', error); } }, }));