diff --git a/src/utils/price.ts b/src/utils/price.ts index 364273d..e14ce62 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -3,6 +3,7 @@ import type { Price, PriceUp } from '@/models/home' import { ref, watch } from 'vue' import { priceSocketConnected, + priceStreamLifecycle, priceWssEnabled, shouldUsePricePolling, } from '@/utils/priceSocketState' @@ -63,14 +64,9 @@ function syncLastPricePolling() { } watch( - () => [priceWssEnabled.value, priceSocketConnected.value] as const, - ([wssEnabled, connected], prev) => { - const prevConnected = prev?.[1] + () => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const, + () => { syncLastPricePolling() - - if (wssEnabled && prevConnected && !connected && lastSubscribers > 0) { - void getLastPrices() - } }, ) diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index 7ddc9c6..e62282c 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -2,14 +2,16 @@ import type { Price } from '@/models/home' import type { Product } from '@/models/products' import { Modal } from 'ant-design-vue' import { getConfigApp } from '@/utils/config' -import { priceSocketConnected, priceWssEnabled } from '@/utils/priceSocketState' +import { priceSocketConnected, priceStreamLifecycle, priceWssEnabled } from '@/utils/priceSocketState' -export { priceSocketConnected, priceWssEnabled, shouldUsePricePolling } from '@/utils/priceSocketState' +export { priceSocketConnected, priceStreamLifecycle, priceWssEnabled, shouldUsePricePolling } from '@/utils/priceSocketState' const RECONNECT_BASE_MS = 3000 const RECONNECT_MAX_MS = 30000 const FAILURES_BEFORE_ALERT = 4 -const STALE_STREAM_MS = 45000 +/** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه می‌کند */ +const STALE_STREAM_MS = 120000 +const FALLBACK_POLL_DELAY_MS = 2500 let eventSource: EventSource | null = null let subscribers = 0 @@ -21,6 +23,7 @@ let reconnectAttempt = 0 let lastStreamEventAt = 0 let networkAlertOpen = false let listenersBound = false +let fallbackPollTimer: ReturnType | undefined function isTruthySetting(value: unknown, defaultValue = true): boolean { if (value === null || value === undefined || value === '') { @@ -95,6 +98,23 @@ function startStaleCheck() { }, 15000) } +function clearFallbackPollTimer() { + if (fallbackPollTimer) { + clearTimeout(fallbackPollTimer) + fallbackPollTimer = undefined + } +} + +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 @@ -211,6 +231,10 @@ function bindStreamEvents(source: EventSource) { } }) + source.addEventListener('ping', () => { + touchStreamActivity() + }) + source.onmessage = () => { touchStreamActivity() } @@ -220,9 +244,10 @@ function handleStreamLost(scheduleRetry = true) { const wasConnected = priceSocketConnected.value disconnectSourceOnly() priceSocketConnected.value = false + priceStreamLifecycle.value = scheduleRetry ? 'connecting' : 'closed' - if (wasConnected || scheduleRetry) { - void runFallbackPoll() + if ((wasConnected || scheduleRetry) && scheduleRetry) { + scheduleFallbackPoll() } if (scheduleRetry && !stopped && subscribers > 0) { @@ -233,6 +258,7 @@ function handleStreamLost(scheduleRetry = true) { function disconnectSourceOnly() { stopStaleCheck() + clearFallbackPollTimer() if (eventSource) { eventSource.onopen = null eventSource.onerror = null @@ -250,6 +276,7 @@ function connect() { const url = resolveStreamUrl() if (!url) { priceSocketConnected.value = false + priceStreamLifecycle.value = 'closed' return } @@ -261,6 +288,8 @@ function connect() { disconnectSourceOnly() } + priceStreamLifecycle.value = 'connecting' + try { eventSource = new EventSource(url) } catch (error) { @@ -274,7 +303,9 @@ function connect() { eventSource.onopen = () => { reconnectAttempt = 0 clearReconnectTimer() + clearFallbackPollTimer() networkAlertOpen = false + priceStreamLifecycle.value = 'open' priceSocketConnected.value = true touchStreamActivity() startStaleCheck() @@ -282,13 +313,7 @@ function connect() { eventSource.onerror = () => { const state = eventSource?.readyState - if (state === EventSource.OPEN) { - return - } - - if (state === EventSource.CONNECTING) { - priceSocketConnected.value = false - void runFallbackPoll() + if (state === EventSource.OPEN || state === EventSource.CONNECTING) { return } @@ -298,8 +323,10 @@ function connect() { function disconnect() { clearReconnectTimer() + clearFallbackPollTimer() disconnectSourceOnly() priceSocketConnected.value = false + priceStreamLifecycle.value = 'idle' } export function forceReconnect() { diff --git a/src/utils/priceSocketState.ts b/src/utils/priceSocketState.ts index 26abfa4..c6a02a7 100644 --- a/src/utils/priceSocketState.ts +++ b/src/utils/priceSocketState.ts @@ -6,11 +6,23 @@ export const priceSocketConnected = ref(false) /** از settings در bootstrap ست می‌شود (کلید wss_enabled = قیمت لحظه‌ای روشن) */ export const priceWssEnabled = ref(false) -/** polling: realtime خاموش = همیشه | روشن + SSE وصل = نه | روشن + SSE قطع = بله */ +export type PriceStreamLifecycle = 'idle' | 'connecting' | 'open' | 'closed' + +/** چرخهٔ اتصال SSE — برای جلوگیری از polling همزمان با reconnect */ +export const priceStreamLifecycle = ref('idle') + +/** polling: realtime خاموش = همیشه | SSE در حال اتصال/وصل = نه | قطع واقعی = بله */ export function shouldUsePricePolling(): boolean { if (!priceWssEnabled.value) { return true } - return !priceSocketConnected.value + if ( + priceStreamLifecycle.value === 'connecting' + || priceStreamLifecycle.value === 'open' + ) { + return false + } + + return true } diff --git a/src/views/base/products/composables/useProductsPageData.ts b/src/views/base/products/composables/useProductsPageData.ts index 34ad94a..3b9daaa 100644 --- a/src/views/base/products/composables/useProductsPageData.ts +++ b/src/views/base/products/composables/useProductsPageData.ts @@ -7,6 +7,7 @@ import { loadProductCategoryTabNames } from '@/utils/categoryTabs' import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price' import { priceSocketConnected, + priceStreamLifecycle, priceWssEnabled, shouldUsePricePolling, } from '@/utils/priceSocketState' @@ -127,14 +128,9 @@ function syncProductListPolling() { } watch( - () => [priceWssEnabled.value, priceSocketConnected.value] as const, - ([wssEnabled, connected], prev) => { - const prevConnected = prev?.[1] + () => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const, + () => { syncProductListPolling() - - if (wssEnabled && prevConnected && !connected && initialized.value) { - void refreshProductsPageData() - } }, ) @@ -158,7 +154,9 @@ export function onProductsPageProviderMounted() { providerMountCount += 1 pricePollingMountCount += 1 if (pricePollingMountCount === 1) { - void getLastPrices() + if (shouldUsePricePolling()) { + void getLastPrices() + } startLastPricePolling() } }