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:
+40
-1
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user