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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-27 16:54:16 +03:30
co-authored by Cursor
parent 2d491eb7a3
commit 5ffd246f61
3 changed files with 249 additions and 22 deletions
+40 -1
View File
@@ -18,6 +18,38 @@ export const price = ref<Price>()
export const lastPrice = ref<Price>()
export const olderPrice = ref<number | undefined>(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<PriceUp | undefined> {
}
}
export async function getLastPrices() {
export async function getLastPrices(): Promise<boolean> {
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() {
+193 -16
View File
@@ -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<typeof setInterval> | undefined
let reconnectAttempt = 0
let lastStreamEventAt = 0
let networkAlertOpen = false
let lastNetworkAlertAt = 0
let listenersBound = false
let fallbackPollTimer: ReturnType<typeof setTimeout> | undefined
let fallbackPollInterval: ReturnType<typeof setInterval> | 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<boolean> {
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) {
+16 -5
View File
@@ -11,16 +11,27 @@ export type PriceStreamLifecycle = 'idle' | 'connecting' | 'open' | 'closed'
/** چرخهٔ اتصال SSE — برای جلوگیری از polling همزمان با reconnect */
export const priceStreamLifecycle = ref<PriceStreamLifecycle>('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
}