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:
parent
94f2de4a64
commit
8e38342d53
@ -29,7 +29,7 @@ class RPCClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async call<T>(options: RPCOptions): Promise<T> {
|
async call<T>(options: RPCOptions): Promise<T> {
|
||||||
const { method, params = {}, timeout = 30000 } = options
|
const { method, params = {}, timeout = 15000 } = options
|
||||||
const maxRetries = 3
|
const maxRetries = 3
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
@ -77,7 +77,8 @@ class RPCClient {
|
|||||||
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
const isRetryable = response.status === 502 || response.status === 503
|
const isRetryable = response.status === 502 || response.status === 503
|
||||||
if (isRetryable && attempt < maxRetries - 1) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
@ -96,7 +97,8 @@ class RPCClient {
|
|||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
const timeoutErr = new Error('Request timeout')
|
const timeoutErr = new Error('Request timeout')
|
||||||
if (attempt < maxRetries - 1) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
throw timeoutErr
|
throw timeoutErr
|
||||||
@ -104,7 +106,8 @@ class RPCClient {
|
|||||||
const msg = error.message
|
const msg = error.message
|
||||||
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
|
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
|
||||||
if (isRetryable && attempt < maxRetries - 1) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export class WebSocketClient {
|
|||||||
private heartbeatInterval = 10000 // Check connection every 10 seconds
|
private heartbeatInterval = 10000 // Check connection every 10 seconds
|
||||||
private pingInterval = 30000 // Send ping every 30 seconds
|
private pingInterval = 30000 // Send ping every 30 seconds
|
||||||
private _state: ConnectionState = 'disconnected'
|
private _state: ConnectionState = 'disconnected'
|
||||||
|
private isReconnecting = false
|
||||||
|
private parseErrorCount = 0
|
||||||
|
|
||||||
constructor(url: string = '/ws/db') {
|
constructor(url: string = '/ws/db') {
|
||||||
this.url = url
|
this.url = url
|
||||||
@ -165,9 +167,15 @@ export class WebSocketClient {
|
|||||||
this.lastMessageTime = Date.now()
|
this.lastMessageTime = Date.now()
|
||||||
try {
|
try {
|
||||||
const update: Update = JSON.parse(event.data)
|
const update: Update = JSON.parse(event.data)
|
||||||
|
this.parseErrorCount = 0
|
||||||
this.callbacks.forEach((callback) => callback(update))
|
this.callbacks.forEach((callback) => callback(update))
|
||||||
} catch (error) {
|
} 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) {
|
if (!this.shouldReconnect) {
|
||||||
return
|
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)
|
// Don't increment attempts for expected disconnects (HMR, normal closure)
|
||||||
if (!isHMR && !isNormalClosure) {
|
if (!isHMR && !isNormalClosure) {
|
||||||
this.reconnectAttempts++
|
this.reconnectAttempts++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
|
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)
|
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
|
||||||
// onclose will be called again and will retry
|
// onclose will be called again and will retry
|
||||||
})
|
})
|
||||||
|
|||||||
@ -103,7 +103,7 @@ export const useAIPermissionsStore = defineStore('aiPermissions', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled.value]))
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled.value])) } catch { /* localStorage full or unavailable */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEnabled(category: AIContextCategory): boolean {
|
function isEnabled(category: AIContextCategory): boolean {
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
sessionValidated = true
|
sessionValidated = true
|
||||||
localStorage.setItem('neode-auth', 'true')
|
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
|
||||||
|
|
||||||
// Initialize data structure immediately so dashboard can render
|
// Initialize data structure immediately so dashboard can render
|
||||||
await initializeData()
|
await initializeData()
|
||||||
@ -62,7 +62,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
async function completeLoginAfterTotp(): Promise<void> {
|
async function completeLoginAfterTotp(): Promise<void> {
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
sessionValidated = true
|
sessionValidated = true
|
||||||
localStorage.setItem('neode-auth', 'true')
|
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
|
||||||
await initializeData()
|
await initializeData()
|
||||||
connectWebSocket().catch((err) => {
|
connectWebSocket().catch((err) => {
|
||||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
||||||
|
|||||||
@ -75,7 +75,7 @@ function getApprovedOrigins(): Set<string> {
|
|||||||
function saveApprovedOrigin(origin: string) {
|
function saveApprovedOrigin(origin: string) {
|
||||||
const origins = getApprovedOrigins()
|
const origins = getApprovedOrigins()
|
||||||
origins.add(origin)
|
origins.add(origin)
|
||||||
localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins]))
|
try { localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins])) } catch { /* localStorage full or unavailable */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NostrConsentRequest {
|
export interface NostrConsentRequest {
|
||||||
|
|||||||
@ -13,10 +13,20 @@ const APP_ALIASES: Record<string, string[]> = {
|
|||||||
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
|
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pre-built reverse lookup: variant pkgId → Set of canonical appIds it matches */
|
||||||
|
const ALIAS_REVERSE = new Map<string, Set<string>>()
|
||||||
|
for (const [canonical, aliases] of Object.entries(APP_ALIASES)) {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
let set = ALIAS_REVERSE.get(alias)
|
||||||
|
if (!set) { set = new Set(); ALIAS_REVERSE.set(alias, set) }
|
||||||
|
set.add(canonical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function matchesAppId(pkgId: string, appId: string): boolean {
|
function matchesAppId(pkgId: string, appId: string): boolean {
|
||||||
if (pkgId === appId) return true
|
if (pkgId === appId) return true
|
||||||
const aliases = APP_ALIASES[appId]
|
const reverseHits = ALIAS_REVERSE.get(pkgId)
|
||||||
return aliases ? aliases.includes(pkgId) : false
|
return reverseHits ? reverseHits.has(appId) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGoalStore = defineStore('goals', () => {
|
export const useGoalStore = defineStore('goals', () => {
|
||||||
@ -32,7 +42,7 @@ export const useGoalStore = defineStore('goals', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress.value))
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(progress.value)) } catch { /* localStorage full or unavailable */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGoalStatus(goalId: string): GoalStatus {
|
function getGoalStatus(goalId: string): GoalStatus {
|
||||||
|
|||||||
@ -16,13 +16,13 @@ export const useUIModeStore = defineStore('uiMode', () => {
|
|||||||
function syncFromBackend(backendMode: UIMode | undefined) {
|
function syncFromBackend(backendMode: UIMode | undefined) {
|
||||||
if (backendMode && ['gamer', 'easy', 'chat'].includes(backendMode)) {
|
if (backendMode && ['gamer', 'easy', 'chat'].includes(backendMode)) {
|
||||||
mode.value = backendMode
|
mode.value = backendMode
|
||||||
localStorage.setItem(STORAGE_KEY, backendMode)
|
try { localStorage.setItem(STORAGE_KEY, backendMode) } catch { /* localStorage full or unavailable */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(newMode: UIMode) {
|
function setMode(newMode: UIMode) {
|
||||||
mode.value = newMode
|
mode.value = newMode
|
||||||
localStorage.setItem(STORAGE_KEY, newMode)
|
try { localStorage.setItem(STORAGE_KEY, newMode) } catch { /* localStorage full or unavailable */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleMode(): UIMode {
|
function cycleMode(): UIMode {
|
||||||
|
|||||||
@ -661,6 +661,7 @@ const gamerDesktopNav: NavItem[] = [
|
|||||||
{ path: '/dashboard/mesh', label: 'Mesh', icon: 'mesh' },
|
{ path: '/dashboard/mesh', label: 'Mesh', icon: 'mesh' },
|
||||||
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
|
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
|
||||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
|
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
|
||||||
|
{ path: '/dashboard/fleet', label: 'Fleet', icon: 'fleet' },
|
||||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -718,6 +719,7 @@ function getIconPath(iconName: string): string[] {
|
|||||||
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
|
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
|
||||||
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
|
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
|
||||||
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
|
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
|
||||||
|
fleet: ['M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'],
|
||||||
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||||
settings: [
|
settings: [
|
||||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||||
@ -751,6 +753,7 @@ const tabOrder = [
|
|||||||
'/dashboard/mesh',
|
'/dashboard/mesh',
|
||||||
'/dashboard/server',
|
'/dashboard/server',
|
||||||
'/dashboard/web5',
|
'/dashboard/web5',
|
||||||
|
'/dashboard/fleet',
|
||||||
'/dashboard/chat',
|
'/dashboard/chat',
|
||||||
'/dashboard/settings'
|
'/dashboard/settings'
|
||||||
]
|
]
|
||||||
|
|||||||
@ -151,5 +151,12 @@ export default defineConfig({
|
|||||||
// Output to dist for Docker builds, or to ../web/dist/neode-ui for local development
|
// Output to dist for Docker builds, or to ../web/dist/neode-ui for local development
|
||||||
outDir: process.env.DOCKER_BUILD === 'true' ? 'dist' : '../web/dist/neode-ui',
|
outDir: process.env.DOCKER_BUILD === 'true' ? 'dist' : '../web/dist/neode-ui',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vendor: ['vue', 'vue-router', 'pinia'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user