import { defineStore } from "pinia"; import { ref, computed } from "vue"; import { useAuthStore } from "./auth"; import { useToast } from "vue-toastification"; const toast = useToast(); export const useWebSocketStore = defineStore("websocket", () => { // State const ws = ref(null); const isConnected = ref(false); const isConnecting = ref(false); const reconnectAttempts = ref(0); const maxReconnectAttempts = ref(5); const reconnectDelay = ref(1000); const heartbeatInterval = ref(null); const reconnectTimeout = ref(null); const messageQueue = ref([]); const listeners = ref(new Map()); // Computed const connectionStatus = computed(() => { if (isConnected.value) return "connected"; if (isConnecting.value) return "connecting"; return "disconnected"; }); const canReconnect = computed(() => { return reconnectAttempts.value < maxReconnectAttempts.value; }); // Helper functions const getWebSocketUrl = async () => { // Use environment variable or fallback if (import.meta.env.VITE_WS_URL) { return import.meta.env.VITE_WS_URL; } // Try to fetch from backend config API try { const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:3000"; const response = await fetch(`${apiUrl}/api/config/public`); const data = await response.json(); if (data.success && data.config.websocket?.url) { return data.config.websocket.url; } } catch (error) { console.warn("Failed to fetch WebSocket URL from config:", error); } // In production, use main API domain with /ws path if (import.meta.env.PROD) { const apiUrl = import.meta.env.VITE_API_URL || "https://api.turbotrades.dev"; // Convert http(s):// to ws(s):// return apiUrl.replace(/^http/, "ws") + "/ws"; } // In development, use current host with ws path (for proxy) const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const host = window.location.host; return `${protocol}//${host}/ws`; }; const clearHeartbeat = () => { if (heartbeatInterval.value) { clearInterval(heartbeatInterval.value); heartbeatInterval.value = null; } }; const clearReconnectTimeout = () => { if (reconnectTimeout.value) { clearTimeout(reconnectTimeout.value); reconnectTimeout.value = null; } }; const startHeartbeat = () => { clearHeartbeat(); // Send ping every 30 seconds heartbeatInterval.value = setInterval(() => { if (isConnected.value && ws.value?.readyState === WebSocket.OPEN) { send({ type: "ping" }); } }, 30000); }; // Actions const connect = async () => { if (ws.value?.readyState === WebSocket.OPEN || isConnecting.value) { console.log("WebSocket already connected or connecting"); return; } isConnecting.value = true; clearReconnectTimeout(); try { const wsUrl = await getWebSocketUrl(); console.log("Connecting to WebSocket:", wsUrl); ws.value = new WebSocket(wsUrl); ws.value.onopen = () => { console.log("WebSocket connected"); isConnected.value = true; isConnecting.value = false; reconnectAttempts.value = 0; startHeartbeat(); // Send queued messages while (messageQueue.value.length > 0) { const message = messageQueue.value.shift(); send(message); } // Emit connected event emit("connected", { timestamp: Date.now() }); }; ws.value.onmessage = (event) => { try { const data = JSON.parse(event.data); console.log("WebSocket message received:", data); handleMessage(data); } catch (error) { console.error("Failed to parse WebSocket message:", error); } }; ws.value.onerror = (error) => { console.error("WebSocket error:", error); isConnecting.value = false; }; ws.value.onclose = (event) => { console.log("WebSocket closed:", event.code, event.reason); isConnected.value = false; isConnecting.value = false; clearHeartbeat(); // Emit disconnected event emit("disconnected", { code: event.code, reason: event.reason, timestamp: Date.now(), }); // Attempt to reconnect if (!event.wasClean && canReconnect.value) { scheduleReconnect(); } }; } catch (error) { console.error("Failed to create WebSocket connection:", error); isConnecting.value = false; } }; const disconnect = () => { clearHeartbeat(); clearReconnectTimeout(); reconnectAttempts.value = maxReconnectAttempts.value; // Prevent auto-reconnect if (ws.value) { ws.value.close(1000, "Client disconnect"); ws.value = null; } isConnected.value = false; isConnecting.value = false; }; const scheduleReconnect = () => { if (!canReconnect.value) { console.log("Max reconnect attempts reached"); toast.error("Lost connection to server. Please refresh the page."); return; } reconnectAttempts.value++; const delay = reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1); console.log( `Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts.value})` ); clearReconnectTimeout(); reconnectTimeout.value = setTimeout(() => { connect(); }, delay); }; const send = (message) => { if (!ws.value || ws.value.readyState !== WebSocket.OPEN) { console.warn("WebSocket not connected, queueing message:", message); messageQueue.value.push(message); return false; } try { const payload = typeof message === "string" ? message : JSON.stringify(message); ws.value.send(payload); return true; } catch (error) { console.error("Failed to send WebSocket message:", error); return false; } }; const handleMessage = (data) => { const { type, data: payload, timestamp } = data; switch (type) { case "connected": console.log("Server confirmed connection:", payload); break; case "pong": // Heartbeat response break; case "notification": if (payload?.message) { toast.info(payload.message); } break; case "balance_update": // Update user balance const authStore = useAuthStore(); if (payload?.balance !== undefined) { authStore.updateBalance(payload.balance); } break; case "item_sold": toast.success( `Your item "${payload?.itemName || "item"}" has been sold!` ); break; case "item_purchased": toast.success( `Successfully purchased "${payload?.itemName || "item"}"!` ); break; case "trade_status": if (payload?.status === "completed") { toast.success("Trade completed successfully!"); } else if (payload?.status === "failed") { toast.error(`Trade failed: ${payload?.reason || "Unknown error"}`); } break; case "price_update": case "listing_update": case "market_update": // These will be handled by listeners break; case "announcement": if (payload?.message) { toast.warning(payload.message, { timeout: 10000 }); } break; case "error": console.error("Server error:", payload); if (payload?.message) { toast.error(payload.message); } break; default: console.log("Unhandled message type:", type); } // Emit to listeners emit(type, payload); }; const on = (event, callback) => { if (!listeners.value.has(event)) { listeners.value.set(event, []); } listeners.value.get(event).push(callback); // Return unsubscribe function return () => off(event, callback); }; const off = (event, callback) => { if (!listeners.value.has(event)) return; const callbacks = listeners.value.get(event); const index = callbacks.indexOf(callback); if (index > -1) { callbacks.splice(index, 1); } if (callbacks.length === 0) { listeners.value.delete(event); } }; const emit = (event, data) => { if (!listeners.value.has(event)) return; const callbacks = listeners.value.get(event); callbacks.forEach((callback) => { try { callback(data); } catch (error) { console.error(`Error in event listener for "${event}":`, error); } }); }; const once = (event, callback) => { const wrappedCallback = (data) => { callback(data); off(event, wrappedCallback); }; return on(event, wrappedCallback); }; const clearListeners = () => { listeners.value.clear(); }; // Ping the server const ping = () => { send({ type: "ping" }); }; return { // State ws, isConnected, isConnecting, reconnectAttempts, maxReconnectAttempts, messageQueue, // Computed connectionStatus, canReconnect, // Actions connect, disconnect, send, on, off, once, emit, clearListeners, ping, }; });