This commit is contained in:
2026-08-22 21:50:39 +03:30
parent 444178df05
commit be59cca162
5 changed files with 215 additions and 43 deletions
+8
View File
@@ -50,10 +50,18 @@ function hasValidPricePayload(data?: Price | null): boolean {
export function markPriceSourceReceived() {
lastPriceSourceAt = Date.now()
consecutivePriceFetchFailures = 0
void import('@/utils/priceSocket').then(({ clearPriceNetworkAlert }) => {
clearPriceNetworkAlert()
})
}
export function markPriceSourceFetchFailed() {
consecutivePriceFetchFailures += 1
if (consecutivePriceFetchFailures >= 2) {
void import('@/utils/priceSocket').then(({ notifyPriceLastFetchFailed }) => {
notifyPriceLastFetchFailed()
})
}
}
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
+111 -37
View File
@@ -24,15 +24,99 @@ export {
const RECONNECT_BASE_MS = 3000
const RECONNECT_MAX_MS = 30000
const FAILURES_BEFORE_ALERT = 4
/** چند بار خطای پشت‌سرهم /api/price/last قبل از نمایش مدال */
const LAST_FETCH_FAILURES_BEFORE_ALERT = 2
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
const STREAM_CONNECTING = 0
const STREAM_OPEN = 1
const STREAM_CLOSED = 2
let eventSource: EventSource | null = null
class AuthPriceStream extends EventTarget {
readyState = STREAM_CONNECTING
onopen: (() => void) | null = null
onerror: (() => void) | null = null
onmessage: (() => void) | null = null
private abort = new AbortController()
constructor(url: string) {
super()
void this.start(url)
}
close() {
this.readyState = STREAM_CLOSED
this.abort.abort()
}
private async start(url: string) {
try {
const response = await fetch(url, {
headers: { Accept: 'text/event-stream' },
cache: 'no-store',
signal: this.abort.signal,
})
if (!response.ok || !response.body) {
this.readyState = STREAM_CLOSED
this.onerror?.()
return
}
this.readyState = STREAM_OPEN
this.onopen?.()
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushSse(buffer)
}
this.readyState = STREAM_CLOSED
this.onerror?.()
} catch {
if (this.abort.signal.aborted) {
return
}
this.readyState = STREAM_CLOSED
this.onerror?.()
}
}
private flushSse(buffer: string): string {
const blocks = buffer.split('\n\n')
const rest = blocks.pop() ?? ''
for (const block of blocks) {
let event = 'message'
const data: string[] = []
for (const line of block.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
this.dispatchEvent(new MessageEvent(event, { data: data.join('\n') }))
this.onmessage?.()
}
return rest
}
}
let eventSource: AuthPriceStream | null = null
let subscribers = 0
let stopped = false
let getSetting: ((key: string) => unknown) | null = null
@@ -131,7 +215,7 @@ function startStaleCheck() {
stopped
|| subscribers <= 0
|| !eventSource
|| eventSource.readyState !== EventSource.OPEN
|| eventSource.readyState !== STREAM_OPEN
) {
return
}
@@ -184,31 +268,40 @@ function scheduleFallbackPoll() {
}, FALLBACK_POLL_DELAY_MS)
}
export function clearPriceNetworkAlert() {
networkAlertOpen = false
pendingPriceAlert = false
}
/** وقتی /api/price/last خطا می‌دهد (از polling عادی یا fallback) */
export function notifyPriceLastFetchFailed() {
void maybeShowNetworkAlert()
}
async function runFallbackPoll() {
if (!priceWssEnabled.value || priceSocketConnected.value) {
return
}
try {
const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import(
'@/utils/price'
)
const { getLastPrices, hasRecentPriceFromSource } = await import('@/utils/price')
const { refreshProductsPageData } = await import(
'@/views/base/products/composables/useProductsPageData'
)
const [lastResult] = await Promise.allSettled([
const [priceResult] = await Promise.allSettled([
getLastPrices(),
refreshProductsPageData(),
])
const priceOk =
lastResult.status === 'fulfilled'
&& lastResult.value === true
priceResult.status === 'fulfilled'
&& priceResult.value === true
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
if (!priceOk) {
markPriceSourceFetchFailed()
if (priceOk) {
clearPriceNetworkAlert()
return
}
void maybeShowNetworkAlert()
@@ -237,25 +330,7 @@ async function isRealPriceOutage(): Promise<boolean> {
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
return consecutivePriceSourceFailures() >= LAST_FETCH_FAILURES_BEFORE_ALERT
}
function showNetworkAlert() {
@@ -316,7 +391,6 @@ function scheduleReconnect() {
reconnectTimer = setTimeout(() => {
reconnectAttempt += 1
connect()
maybeShowNetworkAlert()
}, delay)
}
@@ -384,7 +458,7 @@ async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown
}
}
function bindStreamEvents(source: EventSource) {
function bindStreamEvents(source: AuthPriceStream) {
source.addEventListener('last', (event) => {
try {
handleLastPayload(JSON.parse(event.data))
@@ -475,7 +549,7 @@ function connect() {
if (eventSource) {
const state = eventSource.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
disconnectSourceOnly()
@@ -484,7 +558,7 @@ function connect() {
markConnectingLifecycle()
try {
eventSource = new EventSource(url)
eventSource = new AuthPriceStream(url)
} catch (error) {
console.warn('Price SSE connect failed', error)
handleStreamLost()
@@ -507,7 +581,7 @@ function connect() {
eventSource.onerror = () => {
const state = eventSource?.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
@@ -621,7 +695,7 @@ function onVisibilityChange() {
void maybeShowNetworkAlert()
}
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
if (!eventSource || eventSource.readyState !== STREAM_OPEN) {
forceReconnect()
}
}
@@ -631,7 +705,7 @@ function onPageShow(event: PageTransitionEvent) {
return
}
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
if (event.persisted || !eventSource || eventSource.readyState !== STREAM_OPEN) {
reconnectAttempt = 0
networkAlertOpen = false
forceReconnect()