import type { Price } from '@/models/home' import type { Product } from '@/models/products' import { Modal } from 'ant-design-vue' import { getConfigApp } from '@/utils/config' import { applyProductsListFromStream } from '@/utils/productStreamBridge' import { syncCatalogFromStream } from '@/composables/useProductsCatalog' import { priceSocketConnected, priceStreamConnectingSince, priceStreamLifecycle, priceStreamList, priceStreamProducts, priceStreamServerUnix, priceWssEnabled, } from '@/utils/priceSocketState' import { applyBoardSpecialToLastPayload, setSpecialPricingPayload, } from '@/utils/specialPricing' export { priceSocketConnected, priceStreamConnectingSince, priceStreamLifecycle, priceWssEnabled, shouldUsePricePolling, } from '@/utils/priceSocketState' const RECONNECT_BASE_MS = 3000 const RECONNECT_MAX_MS = 30000 /** چند بار خطای پشت‌سرهم /api/price/last قبل از نمایش مدال */ const LAST_FETCH_FAILURES_BEFORE_ALERT = 2 const ALERT_COOLDOWN_MS = 5 * 60 * 1000 const FALLBACK_POLL_INTERVAL_MS = 10000 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 const STREAM_CONNECTING = 0 const STREAM_OPEN = 1 const STREAM_CLOSED = 2 class AuthPriceStream extends EventTarget { readyState = STREAM_CONNECTING onopen: (() => void) | null = null onerror: (() => void) | null = null onmessage: (() => void) | null = null private abort = new AbortController() constructor(url: string, tvKey = '') { super() void this.start(url, tvKey) } close() { this.readyState = STREAM_CLOSED this.abort.abort() } private async start(url: string, tvKey = '') { try { const headers: Record = { Accept: 'text/event-stream' } if (tvKey) headers['X-Tv-Key'] = tvKey const response = await fetch(url, { headers, cache: 'no-store', signal: this.abort.signal, }) if (!response.ok || !response.body) { this.readyState = STREAM_CLOSED this.onerror?.() return } this.readyState = STREAM_OPEN this.onopen?.() const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) { break } buffer += decoder.decode(value, { stream: true }) buffer = this.flushSse(buffer) } this.readyState = STREAM_CLOSED this.onerror?.() } catch { if (this.abort.signal.aborted) { return } this.readyState = STREAM_CLOSED this.onerror?.() } } private flushSse(buffer: string): string { const blocks = buffer.split('\n\n') const rest = blocks.pop() ?? '' for (const block of blocks) { let event = 'message' const data: string[] = [] for (const line of block.split('\n')) { if (line.startsWith('event:')) { event = line.slice(6).trim() } else if (line.startsWith('data:')) { data.push(line.slice(5).trimStart()) } } this.dispatchEvent(new MessageEvent(event, { data: data.join('\n') })) this.onmessage?.() } return rest } } let eventSource: AuthPriceStream | null = null let subscribers = 0 let tvStreamKey: string | null = null 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 base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '') return `${base}${resolveStreamPath()}` } 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 !== STREAM_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) } export function clearPriceNetworkAlert() { networkAlertOpen = false pendingPriceAlert = false } /** وقتی /api/price/last خطا می‌دهد (از polling عادی یا fallback) */ export function notifyPriceLastFetchFailed() { void maybeShowNetworkAlert() } async function runFallbackPoll() { if (tvStreamKey || !priceWssEnabled.value || priceSocketConnected.value) { return } try { const { getLastPrices, hasRecentPriceFromSource } = 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) { clearPriceNetworkAlert() return } 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 } return consecutivePriceSourceFailures() >= LAST_FETCH_FAILURES_BEFORE_ALERT } 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() }, delay) } async function applyLastFromStream(last: Price) { const { applyLastPricePayload } = await import('@/utils/price') applyLastPricePayload(last) } async function applySettingsFromStream(payload: unknown) { const items = Array.isArray(payload) ? payload : payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items) ? (payload as { items: unknown[] }).items : null if (!items?.length) { return } try { const { useSettingsStore } = await import('@/store/settingsStore') const { touchStoreHoursSettings } = await import('@/utils/storeHours') useSettingsStore().mergeSettings(items as Array<{ key?: string; value?: unknown }>) touchStoreHoursSettings() } catch (error) { console.warn('Settings SSE apply failed', error) } } function handleLastPayload(payload: unknown) { if (!payload || typeof payload !== 'object') { return } const raw = payload as Price & { settings_patch?: unknown } const last = applyBoardSpecialToLastPayload(raw) const hasDisplayable = Number(last.buy ?? 0) > 0 || Number(last.sell ?? 0) > 0 || Number(last.price_last ?? 0) > 0 || Number(last.price ?? 0) > 0 const hasStatus = last.status != null || typeof last.closed === 'boolean' if (!hasDisplayable && !hasStatus) { if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) { touchStreamActivity() void applySettingsFromStream(raw.settings_patch) } return } // SSE گاهی last خالی/no_quote می‌فرستد؛ قیمت معتبر فعلی را پاک نکن if (!hasDisplayable) { if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) { touchStreamActivity() void applySettingsFromStream(raw.settings_patch) } void import('@/utils/price').then(({ getTradeBuyPrice, lastPrice }) => { const keepCurrent = getTradeBuyPrice(lastPrice.value) > 0 && last.status !== 'closed' touchStreamActivity() if (keepCurrent) return void applyLastFromStream(last) }) return } touchStreamActivity() void applyLastFromStream(last) } function handleSettingsPayload(payload: unknown) { touchStreamActivity() void applySettingsFromStream(payload) } function coerceStreamProductList(payload: unknown): Product[] { if (Array.isArray(payload)) { return payload as Product[] } if (!payload || typeof payload !== 'object') { return [] } const root = payload as Record if (Array.isArray(root.data)) return root.data as Product[] if (Array.isArray(root.products)) return root.products as Product[] if (Array.isArray(root.items)) return root.items as Product[] if (root.id != null && ('price' in root || 'title' in root || 'group_type' in root)) { return [payload as Product] } const values = Object.values(root) if ( values.length > 0 && values.every((item) => item && typeof item === 'object' && 'id' in (item as object)) ) { return values as Product[] } return [] } function handleListPayload(payload: unknown) { const products = coerceStreamProductList(payload) if (!products.length) { return } touchStreamActivity() priceStreamList.value = products as Array> syncCatalogFromStream(products) applyProductsListFromStream(products) } function handleAppNotificationPayload(payload: unknown) { if (!payload || typeof payload !== 'object') { return } touchStreamActivity() void applyAppNotificationFromStream(payload as Record) } function handleUnreadCountPayload(payload: unknown) { if (!payload || typeof payload !== 'object') { return } touchStreamActivity() void applyUnreadCountFromStreamPayload(payload as Record) } async function applyAppNotificationFromStream(payload: Record) { try { const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge') bumpUnreadFromStream(payload) } catch (error) { console.warn('App notification SSE apply failed', error) } } async function applyUnreadCountFromStreamPayload(payload: Record) { try { const { applyUnreadCountFromStream } = await import('@/composables/useAppNotificationBadge') applyUnreadCountFromStream(payload) } catch (error) { console.warn('Unread-count SSE apply failed', error) } } function bindStreamEvents(source: AuthPriceStream) { source.addEventListener('last', (event) => { try { handleLastPayload(JSON.parse(event.data)) } catch (error) { console.warn('Price SSE last parse failed', error) } }) source.addEventListener('settings', (event) => { try { handleSettingsPayload(JSON.parse(event.data)) } catch (error) { console.warn('Price SSE settings 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('products', (event) => { try { const rows = JSON.parse(event.data) if (Array.isArray(rows) && rows.length) { touchStreamActivity() priceStreamProducts.value = rows } } catch (error) { console.warn('Price SSE products parse failed', error) } }) source.addEventListener('time', (event) => { try { const payload = JSON.parse(event.data) as { unix?: number } const unix = Number(payload?.unix) if (unix > 0) { touchStreamActivity() priceStreamServerUnix.value = unix } } catch (error) { console.warn('Price SSE time parse failed', error) } }) source.addEventListener('app_notification', (event) => { try { handleAppNotificationPayload(JSON.parse(event.data)) } catch (error) { console.warn('App notification SSE parse failed', error) } }) source.addEventListener('unread-count', (event) => { try { handleUnreadCountPayload(JSON.parse(event.data)) } catch (error) { console.warn('Unread-count SSE parse failed', error) } }) source.addEventListener('special-pricing', (event) => { try { setSpecialPricingPayload(JSON.parse(event.data)) } catch (error) { console.warn('Special-pricing SSE parse failed', error) } }) source.addEventListener('colleague_order', (event) => { try { void import('@/composables/useColleagueOrderWait').then(({ emitColleagueOrderFromStream }) => { emitColleagueOrderFromStream(JSON.parse(event.data)) }) } catch (error) { console.warn('Colleague order SSE 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 === STREAM_OPEN || state === STREAM_CONNECTING) { return } disconnectSourceOnly() } markConnectingLifecycle() try { eventSource = new AuthPriceStream(url, tvStreamKey || '') } 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 === STREAM_OPEN || state === STREAM_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 startTvPriceStream( settingGetter: (key: string) => unknown, key: string, ) { const secret = key.trim() if (!secret) return tvStreamKey = secret startPriceSocket(settingGetter) } export function stopTvPriceStream() { if (!tvStreamKey) return tvStreamKey = null stopPriceSocket() } 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 !== STREAM_OPEN) { forceReconnect() } } function onPageShow(event: PageTransitionEvent) { if (stopped || subscribers <= 0) { return } if (event.persisted || !eventSource || eventSource.readyState !== STREAM_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 } 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 initPriceRealtimeChannel(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) }