From 5ffd246f6188327f97833e4b3aeed1a2331395e6 Mon Sep 17 00:00:00 2001 From: adonis Date: Sat, 27 Jun 2026 16:54:16 +0330 Subject: [PATCH] Improve SSE reconnect on mobile and show price alert only on real outage. Track price source freshness so the modal appears when SSE and API both fail, not on transient mobile reconnects. Co-authored-by: Cursor --- src/utils/price.ts | 41 ++++++- src/utils/priceSocket.ts | 209 +++++++++++++++++++++++++++++++--- src/utils/priceSocketState.ts | 21 +++- 3 files changed, 249 insertions(+), 22 deletions(-) diff --git a/src/utils/price.ts b/src/utils/price.ts index e14ce62..82c3afb 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -18,6 +18,38 @@ export const price = ref() export const lastPrice = ref() export const olderPrice = ref(0) +let lastPriceSourceAt = 0 +let consecutivePriceFetchFailures = 0 + +function hasValidPricePayload(data?: Price | null): boolean { + if (!data) { + return false + } + + return ( + getTradeBuyPrice(data) > 0 + || getTradeSellPrice(data) > 0 + || Number(data.price_last ?? data.price ?? 0) > 0 + ) +} + +export function markPriceSourceReceived() { + lastPriceSourceAt = Date.now() + consecutivePriceFetchFailures = 0 +} + +export function markPriceSourceFetchFailed() { + consecutivePriceFetchFailures += 1 +} + +export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean { + return lastPriceSourceAt > 0 && Date.now() - lastPriceSourceAt < maxAgeMs +} + +export function consecutivePriceSourceFailures(): number { + return consecutivePriceFetchFailures +} + /** buy/sell نمایش و معامله — همان buy/sell/price_last از /api/price/last */ export function getTradeBuyPrice(p?: Price | null) { return p?.buy ?? p?.price_last ?? p?.up?.buy ?? 0 @@ -114,7 +146,7 @@ async function fetchUpPrices(): Promise { } } -export async function getLastPrices() { +export async function getLastPrices(): Promise { try { let dataLast = await getLastPrice() @@ -126,8 +158,11 @@ export async function getLastPrices() { } applyLastPricePayload(dataLast) + return hasValidPricePayload(dataLast) } catch (e) { console.log(e) + markPriceSourceFetchFailed() + return false } } @@ -142,6 +177,10 @@ export function applyLastPricePayload(dataLast: Price) { buy: getTradeBuyPrice(normalized), sell: getTradeSellPrice(normalized), } + + if (hasValidPricePayload(normalized)) { + markPriceSourceReceived() + } } export function percentPriceChanged() { diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index cf7d0e9..47dd13e 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -3,13 +3,29 @@ import type { Product } from '@/models/products' import { Modal } from 'ant-design-vue' import { getConfigApp } from '@/utils/config' import { findStoredAuthToken } from '@/utils/authToken' -import { priceSocketConnected, priceStreamLifecycle, priceWssEnabled } from '@/utils/priceSocketState' +import { + priceSocketConnected, + priceStreamConnectingSince, + priceStreamLifecycle, + priceWssEnabled, +} from '@/utils/priceSocketState' -export { priceSocketConnected, priceStreamLifecycle, priceWssEnabled, shouldUsePricePolling } 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 @@ -23,8 +39,34 @@ 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 === '') { @@ -114,6 +156,30 @@ function clearFallbackPollTimer() { } } +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(() => { @@ -130,21 +196,91 @@ async function runFallbackPoll() { } try { - const { getLastPrices } = await import('@/utils/price') + const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import( + '@/utils/price' + ) const { refreshProductsPageData } = await import( '@/views/base/products/composables/useProductsPageData' ) - await Promise.allSettled([getLastPrices(), refreshProductsPageData()]) + + 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: 'اتصال قیمت برقرار نیست', @@ -164,10 +300,12 @@ function showNetworkAlert() { }) } -function maybeShowNetworkAlert() { - if (reconnectAttempt >= FAILURES_BEFORE_ALERT || !navigator.onLine) { - showNetworkAlert() +async function maybeShowNetworkAlert() { + if (!(await isRealPriceOutage())) { + return } + + showNetworkAlert() } function scheduleReconnect() { @@ -278,18 +416,16 @@ function handleStreamLost(scheduleRetry = true) { priceStreamLifecycle.value = scheduleRetry ? 'connecting' : 'closed' if ((wasConnected || scheduleRetry) && scheduleRetry) { - scheduleFallbackPoll() + startFallbackPolling() } if (scheduleRetry && !stopped && subscribers > 0) { scheduleReconnect() - maybeShowNetworkAlert() } } function disconnectSourceOnly() { stopStaleCheck() - clearFallbackPollTimer() if (eventSource) { eventSource.onopen = null eventSource.onerror = null @@ -308,6 +444,7 @@ function connect() { if (!url) { priceSocketConnected.value = false priceStreamLifecycle.value = 'closed' + clearConnectingLifecycle() return } @@ -319,7 +456,7 @@ function connect() { disconnectSourceOnly() } - priceStreamLifecycle.value = 'connecting' + markConnectingLifecycle() try { eventSource = new EventSource(url) @@ -334,9 +471,10 @@ function connect() { eventSource.onopen = () => { reconnectAttempt = 0 clearReconnectTimer() - clearFallbackPollTimer() + stopFallbackPolling() networkAlertOpen = false priceStreamLifecycle.value = 'open' + clearConnectingLifecycle() priceSocketConnected.value = true touchStreamActivity() startStaleCheck() @@ -354,10 +492,11 @@ function connect() { function disconnect() { clearReconnectTimer() - clearFallbackPollTimer() + stopFallbackPolling() disconnectSourceOnly() priceSocketConnected.value = false priceStreamLifecycle.value = 'idle' + clearConnectingLifecycle() } export function forceReconnect() { @@ -382,7 +521,7 @@ function onBrowserOnline() { function onBrowserOffline() { handleStreamLost(true) - showNetworkAlert() + void maybeShowNetworkAlert() } function bindBrowserNetworkListeners() { @@ -415,6 +554,10 @@ export function startPriceSocket(settingGetter: (key: string) => unknown) { if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', onVisibilityChange) } + + if (typeof window !== 'undefined') { + window.addEventListener('pageshow', onPageShow) + } } export function stopPriceSocket() { @@ -430,19 +573,49 @@ export function stopPriceSocket() { if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange) } + + if (typeof window !== 'undefined') { + window.removeEventListener('pageshow', onPageShow) + } } function onVisibilityChange() { - if (document.visibilityState !== 'visible' || stopped || subscribers <= 0) { + if (stopped || subscribers <= 0) { return } - if (!eventSource || eventSource.readyState !== EventSource.OPEN) { + 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)) { @@ -472,6 +645,10 @@ function shutdownPriceRealtimeChannel() { if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange) } + + if (typeof window !== 'undefined') { + window.removeEventListener('pageshow', onPageShow) + } } export function syncPriceRealtimeChannel(settingGetter: (key: string) => unknown) { diff --git a/src/utils/priceSocketState.ts b/src/utils/priceSocketState.ts index c6a02a7..d7a38eb 100644 --- a/src/utils/priceSocketState.ts +++ b/src/utils/priceSocketState.ts @@ -11,16 +11,27 @@ export type PriceStreamLifecycle = 'idle' | 'connecting' | 'open' | 'closed' /** چرخهٔ اتصال SSE — برای جلوگیری از polling همزمان با reconnect */ export const priceStreamLifecycle = ref('idle') -/** polling: realtime خاموش = همیشه | SSE در حال اتصال/وصل = نه | قطع واقعی = بله */ +/** زمان شروع reconnect — برای فعال‌کردن polling پشتیبان روی موبایل */ +export const priceStreamConnectingSince = ref(0) + +const CONNECTING_POLL_AFTER_MS = 8000 + +/** polling: realtime خاموش = همیشه | SSE وصل = نه | reconnect طولانی/قطع = بله */ export function shouldUsePricePolling(): boolean { if (!priceWssEnabled.value) { return true } - if ( - priceStreamLifecycle.value === 'connecting' - || priceStreamLifecycle.value === 'open' - ) { + if (priceStreamLifecycle.value === 'open') { + return false + } + + if (priceStreamLifecycle.value === 'connecting') { + const since = priceStreamConnectingSince.value + if (since > 0 && Date.now() - since >= CONNECTING_POLL_AFTER_MS) { + return true + } + return false }