import type { Price } from '@/models/home' import type { Product } from '@/models/products' import { Modal } from 'ant-design-vue' import { getConfigApp } from '@/utils/config' import { findStoredAuthToken } from '@/utils/authToken' import { priceSocketConnected, priceStreamConnectingSince, priceStreamLifecycle, priceWssEnabled, } from '@/utils/priceSocketState' export { priceSocketConnected, priceStreamConnectingSince, priceStreamLifecycle, priceWssEnabled, shouldUsePricePolling, } from '@/utils/priceSocketState' const RECONNECT_BASE_MS = 3000 const RECONNECT_MAX_MS = 30000 const FAILURES_BEFORE_ALERT = 4 const ALERT_COOLDOWN_MS = 5 * 60 * 1000 const FALLBACK_POLL_INTERVAL_MS = 10000 /** بدون قیمت معتبر از مرجع (SSE یا API) */ const PRICE_SOURCE_STALE_MS_MOBILE = 50000 const PRICE_SOURCE_STALE_MS_DESKTOP = 35000 /** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه می‌کند */ const STALE_STREAM_MS = 120000 const FALLBACK_POLL_DELAY_MS = 2500 let eventSource: EventSource | null = null let subscribers = 0 let stopped = false let getSetting: ((key: string) => unknown) | null = null let reconnectTimer: ReturnType | undefined let staleCheckTimer: ReturnType | undefined let reconnectAttempt = 0 let lastStreamEventAt = 0 let networkAlertOpen = false let lastNetworkAlertAt = 0 let listenersBound = false let fallbackPollTimer: ReturnType | undefined let fallbackPollInterval: ReturnType | undefined let pendingPriceAlert = false function isMobileDevice(): boolean { if (typeof navigator === 'undefined') { return false } return /iPhone|iPad|iPod|Android|Mobile/i.test(navigator.userAgent) } function isPageVisible(): boolean { return typeof document === 'undefined' || document.visibilityState === 'visible' } function markConnectingLifecycle() { priceStreamLifecycle.value = 'connecting' if (priceStreamConnectingSince.value <= 0) { priceStreamConnectingSince.value = Date.now() } } function clearConnectingLifecycle() { priceStreamConnectingSince.value = 0 } function isTruthySetting(value: unknown, defaultValue = true): boolean { if (value === null || value === undefined || value === '') { return defaultValue } return ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase()) } function resolveStreamPath(): string { const ssePath = String(getSetting?.('sse_path') ?? '').trim() if (ssePath && !ssePath.includes('/prc/')) { return ssePath.startsWith('/') ? ssePath : `/${ssePath}` } const legacyPath = String(getSetting?.('wss_path') ?? '').trim() if (legacyPath && !legacyPath.includes('/prc/')) { return legacyPath.startsWith('/') ? legacyPath : `/${legacyPath}` } return '/api/price/stream' } function resolveStreamUrl(): string | null { if (!getSetting) { return null } if (!isTruthySetting(getSetting('wss_enabled'), true)) { return null } const token = findStoredAuthToken() if (!token) { return null } const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '') const url = new URL(`${base}${resolveStreamPath()}`) url.searchParams.set('token', token) return url.toString() } function clearReconnectTimer() { if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = undefined } } function stopStaleCheck() { if (staleCheckTimer) { clearInterval(staleCheckTimer) staleCheckTimer = undefined } } function touchStreamActivity() { lastStreamEventAt = Date.now() } function startStaleCheck() { stopStaleCheck() touchStreamActivity() staleCheckTimer = setInterval(() => { if ( stopped || subscribers <= 0 || !eventSource || eventSource.readyState !== EventSource.OPEN ) { return } if (Date.now() - lastStreamEventAt > STALE_STREAM_MS) { console.warn('Price SSE stale — reconnecting') handleStreamLost(true) } }, 15000) } function clearFallbackPollTimer() { if (fallbackPollTimer) { clearTimeout(fallbackPollTimer) fallbackPollTimer = undefined } } function stopFallbackPolling() { clearFallbackPollTimer() if (fallbackPollInterval) { clearInterval(fallbackPollInterval) fallbackPollInterval = undefined } } function startFallbackPolling() { if (fallbackPollInterval) { return } scheduleFallbackPoll() fallbackPollInterval = setInterval(() => { if (stopped || subscribers <= 0 || priceSocketConnected.value) { stopFallbackPolling() return } void runFallbackPoll() }, FALLBACK_POLL_INTERVAL_MS) } function scheduleFallbackPoll() { clearFallbackPollTimer() fallbackPollTimer = setTimeout(() => { fallbackPollTimer = undefined if (!stopped && subscribers > 0 && !priceSocketConnected.value) { void runFallbackPoll() } }, FALLBACK_POLL_DELAY_MS) } async function runFallbackPoll() { if (!priceWssEnabled.value || priceSocketConnected.value) { return } try { const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import( '@/utils/price' ) const { refreshProductsPageData } = await import( '@/views/base/products/composables/useProductsPageData' ) const [priceResult] = await Promise.allSettled([ getLastPrices(), refreshProductsPageData(), ]) const priceOk = priceResult.status === 'fulfilled' && priceResult.value === true && hasRecentPriceFromSource(getPriceSourceStaleMs()) if (!priceOk) { markPriceSourceFetchFailed() } void maybeShowNetworkAlert() } catch (error) { console.warn('Price fallback poll failed', error) const { markPriceSourceFetchFailed } = await import('@/utils/price') markPriceSourceFetchFailed() void maybeShowNetworkAlert() } } function getPriceSourceStaleMs(): number { return isMobileDevice() ? PRICE_SOURCE_STALE_MS_MOBILE : PRICE_SOURCE_STALE_MS_DESKTOP } async function isRealPriceOutage(): Promise { const { hasRecentPriceFromSource, consecutivePriceSourceFailures, } = await import('@/utils/price') const staleMs = getPriceSourceStaleMs() if (hasRecentPriceFromSource(staleMs)) { pendingPriceAlert = false return false } const fetchFailures = consecutivePriceSourceFailures() if (fetchFailures >= 2) { return true } if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) { return true } if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) { return true } if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) { return true } return false } function showNetworkAlert() { if (networkAlertOpen || stopped || !priceWssEnabled.value) { return } if (!isPageVisible()) { pendingPriceAlert = true return } const now = Date.now() if (now - lastNetworkAlertAt < ALERT_COOLDOWN_MS) { return } lastNetworkAlertAt = now pendingPriceAlert = false networkAlertOpen = true Modal.confirm({ title: 'اتصال قیمت برقرار نیست', content: 'به‌روزرسانی لحظه‌ای قیمت قطع شده است. اتصال اینترنت را بررسی کنید یا دوباره تلاش کنید. تا زمان اتصال مجدد، قیمت با تأخیر به‌روز می‌شود.', okText: 'تلاش مجدد', cancelText: 'بستن', centered: true, onOk: () => { networkAlertOpen = false reconnectAttempt = 0 forceReconnect() }, onCancel: () => { networkAlertOpen = false }, }) } async function maybeShowNetworkAlert() { if (!(await isRealPriceOutage())) { return } showNetworkAlert() } function scheduleReconnect() { if (stopped || subscribers <= 0) { return } clearReconnectTimer() const delay = Math.min( RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt), RECONNECT_MAX_MS, ) reconnectTimer = setTimeout(() => { reconnectAttempt += 1 connect() maybeShowNetworkAlert() }, delay) } async function applyLastFromStream(last: Price) { const { applyLastPricePayload } = await import('@/utils/price') applyLastPricePayload(last) } function handleLastPayload(payload: unknown) { if (!payload || typeof payload !== 'object') { return } const last = payload as Price if (last.price_last == null && last.price == null && last.buy == null) { return } touchStreamActivity() void applyLastFromStream(last) } async function applyListFromStream(products: Product[]) { const { applyProductsListFromSocket } = await import( '@/views/base/products/composables/useProductsPageData' ) applyProductsListFromSocket(products) } async function applyAssetsFromStream(payload: unknown) { const { applyWalletAssetsFromStream } = await import('@/composables/useWalletAssets') applyWalletAssetsFromStream(payload) } function handleListPayload(payload: unknown) { if (!Array.isArray(payload) || !payload.length) { return } touchStreamActivity() void applyListFromStream(payload as Product[]) } function handleAssetsPayload(payload: unknown) { if (!payload || typeof payload !== 'object') { return } touchStreamActivity() void applyAssetsFromStream(payload) } function bindStreamEvents(source: EventSource) { source.addEventListener('last', (event) => { try { handleLastPayload(JSON.parse(event.data)) } catch (error) { console.warn('Price SSE last parse failed', error) } }) source.addEventListener('list', (event) => { try { handleListPayload(JSON.parse(event.data)) } catch (error) { console.warn('Price SSE list parse failed', error) } }) source.addEventListener('assets', (event) => { try { handleAssetsPayload(JSON.parse(event.data)) } catch (error) { console.warn('Price SSE assets parse failed', error) } }) source.addEventListener('ping', () => { touchStreamActivity() }) source.onmessage = () => { touchStreamActivity() } } function handleStreamLost(scheduleRetry = true) { const wasConnected = priceSocketConnected.value disconnectSourceOnly() priceSocketConnected.value = false priceStreamLifecycle.value = scheduleRetry ? 'connecting' : 'closed' if ((wasConnected || scheduleRetry) && scheduleRetry) { startFallbackPolling() } if (scheduleRetry && !stopped && subscribers > 0) { scheduleReconnect() } } function disconnectSourceOnly() { stopStaleCheck() if (eventSource) { eventSource.onopen = null eventSource.onerror = null eventSource.onmessage = null eventSource.close() eventSource = null } } function connect() { if (stopped || subscribers <= 0) { return } const url = resolveStreamUrl() if (!url) { priceSocketConnected.value = false priceStreamLifecycle.value = 'closed' clearConnectingLifecycle() return } if (eventSource) { const state = eventSource.readyState if (state === EventSource.OPEN || state === EventSource.CONNECTING) { return } disconnectSourceOnly() } markConnectingLifecycle() try { eventSource = new EventSource(url) } catch (error) { console.warn('Price SSE connect failed', error) handleStreamLost() return } bindStreamEvents(eventSource) eventSource.onopen = () => { reconnectAttempt = 0 clearReconnectTimer() stopFallbackPolling() networkAlertOpen = false priceStreamLifecycle.value = 'open' clearConnectingLifecycle() priceSocketConnected.value = true touchStreamActivity() startStaleCheck() } eventSource.onerror = () => { const state = eventSource?.readyState if (state === EventSource.OPEN || state === EventSource.CONNECTING) { return } handleStreamLost() } } function disconnect() { clearReconnectTimer() stopFallbackPolling() disconnectSourceOnly() priceSocketConnected.value = false priceStreamLifecycle.value = 'idle' clearConnectingLifecycle() } export function forceReconnect() { if (stopped || subscribers <= 0) { return } clearReconnectTimer() disconnect() connect() } function onBrowserOnline() { if (stopped || subscribers <= 0) { return } reconnectAttempt = 0 networkAlertOpen = false forceReconnect() } function onBrowserOffline() { handleStreamLost(true) void maybeShowNetworkAlert() } function bindBrowserNetworkListeners() { if (listenersBound || typeof window === 'undefined') { return } listenersBound = true window.addEventListener('online', onBrowserOnline) window.addEventListener('offline', onBrowserOffline) } function unbindBrowserNetworkListeners() { if (!listenersBound || typeof window === 'undefined') { return } listenersBound = false window.removeEventListener('online', onBrowserOnline) window.removeEventListener('offline', onBrowserOffline) } export function startPriceSocket(settingGetter: (key: string) => unknown) { getSetting = settingGetter subscribers += 1 stopped = false connect() bindBrowserNetworkListeners() if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', onVisibilityChange) } if (typeof window !== 'undefined') { window.addEventListener('pageshow', onPageShow) } } export function stopPriceSocket() { subscribers = Math.max(0, subscribers - 1) if (subscribers > 0) { return } stopped = true disconnect() unbindBrowserNetworkListeners() if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange) } if (typeof window !== 'undefined') { window.removeEventListener('pageshow', onPageShow) } } function onVisibilityChange() { if (stopped || subscribers <= 0) { return } if (document.visibilityState === 'hidden') { reconnectAttempt = 0 return } reconnectAttempt = 0 networkAlertOpen = false if (pendingPriceAlert) { void maybeShowNetworkAlert() } if (!eventSource || eventSource.readyState !== EventSource.OPEN) { forceReconnect() } } function onPageShow(event: PageTransitionEvent) { if (stopped || subscribers <= 0) { return } if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) { reconnectAttempt = 0 networkAlertOpen = false forceReconnect() if (pendingPriceAlert) { void maybeShowNetworkAlert() } } } export function isPriceWebSocketEnabled(settingGetter: (key: string) => unknown): boolean { getSetting = settingGetter if (!isTruthySetting(settingGetter('wss_enabled'), true)) { return false } if (!findStoredAuthToken()) { return false } return resolveStreamUrl() !== null } let channelStarted = false function shutdownPriceRealtimeChannel() { if (!channelStarted && subscribers <= 0) { return } channelStarted = false stopped = true subscribers = 0 disconnect() unbindBrowserNetworkListeners() if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange) } if (typeof window !== 'undefined') { window.removeEventListener('pageshow', onPageShow) } } export function syncPriceRealtimeChannel(settingGetter: (key: string) => unknown) { getSetting = settingGetter priceWssEnabled.value = isTruthySetting(settingGetter('wss_enabled'), true) if (!priceWssEnabled.value || !isPriceWebSocketEnabled(settingGetter)) { shutdownPriceRealtimeChannel() return } if (channelStarted && subscribers > 0) { forceReconnect() return } channelStarted = true stopped = false startPriceSocket(settingGetter) } export function initPriceRealtimeChannel(settingGetter: (key: string) => unknown) { syncPriceRealtimeChannel(settingGetter) } export function stopPriceRealtimeAuth() { shutdownPriceRealtimeChannel() }