681 lines
16 KiB
TypeScript
681 lines
16 KiB
TypeScript
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 {
|
|
priceSocketConnected,
|
|
priceStreamConnectingSince,
|
|
priceStreamLifecycle,
|
|
priceWssEnabled,
|
|
} 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
|
|
const PRICE_SOURCE_STALE_MS_MOBILE = 50000
|
|
const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
|
|
const STALE_STREAM_MS = 120000
|
|
const FALLBACK_POLL_DELAY_MS = 2500
|
|
|
|
let eventSource: EventSource | null = null
|
|
let subscribers = 0
|
|
let stopped = false
|
|
let getSetting: ((key: string) => unknown) | null = null
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
|
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 === '') {
|
|
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 !== EventSource.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)
|
|
}
|
|
|
|
async function runFallbackPoll() {
|
|
if (!priceWssEnabled.value || priceSocketConnected.value) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import(
|
|
'@/utils/price'
|
|
)
|
|
const { refreshProductsPageData } = await import(
|
|
'@/views/base/products/composables/useProductsPageData'
|
|
)
|
|
|
|
const [lastResult] = await Promise.allSettled([
|
|
getLastPrices(),
|
|
refreshProductsPageData(),
|
|
])
|
|
|
|
const priceOk =
|
|
lastResult.status === 'fulfilled'
|
|
&& lastResult.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: 'اتصال قیمت برقرار نیست',
|
|
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()
|
|
maybeShowNetworkAlert()
|
|
}, delay)
|
|
}
|
|
|
|
async function applyLastFromStream(last: Price) {
|
|
const { applyLastPricePayload } = await import('@/utils/price')
|
|
applyLastPricePayload(last)
|
|
}
|
|
|
|
function handleLastPayload(payload: unknown) {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return
|
|
}
|
|
|
|
const last = payload as Price
|
|
if (last.price_last == null && last.price == null && last.buy == null) {
|
|
return
|
|
}
|
|
|
|
touchStreamActivity()
|
|
void applyLastFromStream(last)
|
|
}
|
|
|
|
function handleListPayload(payload: unknown) {
|
|
if (!Array.isArray(payload) || !payload.length) {
|
|
return
|
|
}
|
|
|
|
touchStreamActivity()
|
|
applyProductsListFromStream(payload as Product[])
|
|
}
|
|
|
|
function handleAppNotificationPayload(payload: unknown) {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return
|
|
}
|
|
|
|
touchStreamActivity()
|
|
void applyAppNotificationFromStream(payload as Record<string, unknown>)
|
|
}
|
|
|
|
function handleUnreadCountPayload(payload: unknown) {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return
|
|
}
|
|
|
|
touchStreamActivity()
|
|
void applyUnreadCountFromStreamPayload(payload as Record<string, unknown>)
|
|
}
|
|
|
|
async function applyAppNotificationFromStream(payload: Record<string, unknown>) {
|
|
try {
|
|
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
|
|
bumpUnreadFromStream(payload)
|
|
} catch (error) {
|
|
console.warn('App notification SSE apply failed', error)
|
|
}
|
|
}
|
|
|
|
async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown>) {
|
|
try {
|
|
const { applyUnreadCountFromStream } = await import('@/composables/useAppNotificationBadge')
|
|
applyUnreadCountFromStream(payload)
|
|
} catch (error) {
|
|
console.warn('Unread-count SSE apply failed', error)
|
|
}
|
|
}
|
|
|
|
function bindStreamEvents(source: EventSource) {
|
|
source.addEventListener('last', (event) => {
|
|
try {
|
|
handleLastPayload(JSON.parse(event.data))
|
|
} catch (error) {
|
|
console.warn('Price SSE last 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('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('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 === EventSource.OPEN || state === EventSource.CONNECTING) {
|
|
return
|
|
}
|
|
disconnectSourceOnly()
|
|
}
|
|
|
|
markConnectingLifecycle()
|
|
|
|
try {
|
|
eventSource = new EventSource(url)
|
|
} 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 === EventSource.OPEN || state === EventSource.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 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 !== 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)) {
|
|
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)
|
|
}
|