Files
boursecu_user/src/utils/priceSocket.ts
T

414 lines
9.5 KiB
TypeScript
Raw Normal View History

2026-06-18 19:09:50 +03:30
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'
export { priceSocketConnected, 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
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 listenersBound = false
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)
}
async function runFallbackPoll() {
if (!priceWssEnabled.value || priceSocketConnected.value) {
return
}
try {
const { getLastPrices } = await import('@/utils/price')
const { refreshProductsPageData } = await import(
'@/views/base/products/composables/useProductsPageData'
)
await Promise.allSettled([getLastPrices(), refreshProductsPageData()])
} catch (error) {
console.warn('Price fallback poll failed', error)
}
}
function showNetworkAlert() {
if (networkAlertOpen || stopped || !priceWssEnabled.value) {
return
}
networkAlertOpen = true
Modal.confirm({
title: 'اتصال قیمت برقرار نیست',
content:
'به‌روزرسانی لحظه‌ای قیمت قطع شده است. اتصال اینترنت را بررسی کنید یا دوباره تلاش کنید. تا زمان اتصال مجدد، قیمت با تأخیر به‌روز می‌شود.',
okText: 'تلاش مجدد',
cancelText: 'بستن',
centered: true,
onOk: () => {
networkAlertOpen = false
reconnectAttempt = 0
forceReconnect()
},
onCancel: () => {
networkAlertOpen = false
},
})
}
function maybeShowNetworkAlert() {
if (reconnectAttempt >= FAILURES_BEFORE_ALERT || !navigator.onLine) {
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)
}
async function applyListFromStream(products: Product[]) {
const { applyProductsListFromSocket } = await import(
'@/views/base/products/composables/useProductsPageData'
)
applyProductsListFromSocket(products)
}
function handleListPayload(payload: unknown) {
if (!Array.isArray(payload) || !payload.length) {
return
}
touchStreamActivity()
void applyListFromStream(payload as Product[])
}
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.onmessage = () => {
touchStreamActivity()
}
}
function handleStreamLost(scheduleRetry = true) {
const wasConnected = priceSocketConnected.value
disconnectSourceOnly()
priceSocketConnected.value = false
if (wasConnected || scheduleRetry) {
void runFallbackPoll()
}
if (scheduleRetry && !stopped && subscribers > 0) {
scheduleReconnect()
maybeShowNetworkAlert()
}
}
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
return
}
if (eventSource) {
const state = eventSource.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
return
}
disconnectSourceOnly()
}
try {
eventSource = new EventSource(url)
} catch (error) {
console.warn('Price SSE connect failed', error)
handleStreamLost()
return
}
bindStreamEvents(eventSource)
eventSource.onopen = () => {
reconnectAttempt = 0
clearReconnectTimer()
networkAlertOpen = false
priceSocketConnected.value = true
touchStreamActivity()
startStaleCheck()
}
eventSource.onerror = () => {
const state = eventSource?.readyState
if (state === EventSource.OPEN) {
return
}
if (state === EventSource.CONNECTING) {
priceSocketConnected.value = false
void runFallbackPoll()
return
}
handleStreamLost()
}
}
function disconnect() {
clearReconnectTimer()
disconnectSourceOnly()
priceSocketConnected.value = false
}
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)
showNetworkAlert()
}
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)
}
}
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)
}
}
function onVisibilityChange() {
if (document.visibilityState !== 'visible' || stopped || subscribers <= 0) {
return
}
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
reconnectAttempt = 0
forceReconnect()
}
}
export function isPriceWebSocketEnabled(settingGetter: (key: string) => unknown): boolean {
getSetting = settingGetter
return resolveStreamUrl() !== null
}
let channelStarted = false
export function initPriceRealtimeChannel(settingGetter: (key: string) => unknown) {
priceWssEnabled.value = isTruthySetting(settingGetter('wss_enabled'), true)
if (channelStarted) {
return
}
channelStarted = true
if (!priceWssEnabled.value) {
return
}
if (!isPriceWebSocketEnabled(settingGetter)) {
return
}
startPriceSocket(settingGetter)
}