fix: WebSocket reconnect race, parse error tracking, RPC timeout reduction, vendor chunk split

- F8: Add isReconnecting flag to prevent parallel reconnection attempts
- F9: Track JSON parse errors, force reconnect after 3 consecutive failures
- F11: Reduce RPC timeout to 15s, add jitter to retry backoff
- F12: Add vendor chunk splitting for vue/router/pinia
- F13: DOMPurify already applied to QR SVGs — verified
- F14: Replace O(n) goals alias lookup with Map-based O(1)
- F15: Wrap 7 localStorage.setItem calls in try/catch across 5 stores

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-21 01:57:05 +00:00
parent f3976ba03a
commit 3b35b1bee0
9 changed files with 58 additions and 17 deletions

View File

@@ -29,7 +29,7 @@ class RPCClient {
}
async call<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 30000 } = options
const { method, params = {}, timeout = 15000 } = options
const maxRetries = 3
for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -77,7 +77,8 @@ class RPCClient {
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503
if (isRetryable && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
const delay = 600 * (attempt + 1)
await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5))))
continue
}
throw err
@@ -96,7 +97,8 @@ class RPCClient {
if (error.name === 'AbortError') {
const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
const delay = 600 * (attempt + 1)
await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5))))
continue
}
throw timeoutErr
@@ -104,7 +106,8 @@ class RPCClient {
const msg = error.message
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
if (isRetryable && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
const delay = 600 * (attempt + 1)
await new Promise((r) => setTimeout(r, Math.floor(delay * (0.5 + Math.random() * 0.5))))
continue
}
throw error

View File

@@ -27,6 +27,8 @@ export class WebSocketClient {
private heartbeatInterval = 10000 // Check connection every 10 seconds
private pingInterval = 30000 // Send ping every 30 seconds
private _state: ConnectionState = 'disconnected'
private isReconnecting = false
private parseErrorCount = 0
constructor(url: string = '/ws/db') {
this.url = url
@@ -165,9 +167,15 @@ export class WebSocketClient {
this.lastMessageTime = Date.now()
try {
const update: Update = JSON.parse(event.data)
this.parseErrorCount = 0
this.callbacks.forEach((callback) => callback(update))
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to parse WebSocket message:', error)
this.parseErrorCount++
if (import.meta.env.DEV) console.error(`Failed to parse WebSocket message (${this.parseErrorCount} consecutive):`, error)
if (this.parseErrorCount > 3) {
if (import.meta.env.DEV) console.warn('[WebSocket] Too many parse errors, closing to trigger reconnection')
this.ws?.close()
}
}
}
@@ -214,14 +222,24 @@ export class WebSocketClient {
if (!this.shouldReconnect) {
return
}
// Prevent parallel reconnections from duplicate onclose events
if (this.isReconnecting) {
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection already in progress, skipping')
return
}
// Don't increment attempts for expected disconnects (HMR, normal closure)
if (!isHMR && !isNormalClosure) {
this.reconnectAttempts++
}
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
this.connect().catch((err) => {
this.isReconnecting = true
this.connect().then(() => {
this.isReconnecting = false
}).catch((err) => {
this.isReconnecting = false
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
// onclose will be called again and will retry
})