2026-01-24 22:59:20 +00:00
|
|
|
// WebSocket handler for real-time updates
|
|
|
|
|
|
|
|
|
|
import type { Update, PatchOperation } from '../types/api'
|
2026-03-04 05:23:42 +00:00
|
|
|
import { applyPatch, type Operation } from 'fast-json-patch'
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
type WebSocketCallback = (update: Update) => void
|
2026-02-01 13:24:03 +00:00
|
|
|
type ConnectionStateCallback = (connected: boolean) => void
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
export class WebSocketClient {
|
|
|
|
|
private ws: WebSocket | null = null
|
|
|
|
|
private callbacks: Set<WebSocketCallback> = new Set()
|
2026-02-01 13:24:03 +00:00
|
|
|
private connectionStateCallbacks: Set<ConnectionStateCallback> = new Set()
|
2026-01-24 22:59:20 +00:00
|
|
|
private reconnectAttempts = 0
|
|
|
|
|
private maxReconnectAttempts = 10
|
|
|
|
|
private reconnectDelay = 1000
|
|
|
|
|
private shouldReconnect = true
|
|
|
|
|
private url: string
|
|
|
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
2026-02-01 13:24:03 +00:00
|
|
|
private visibilityChangeHandler: (() => void) | null = null
|
|
|
|
|
private onlineHandler: (() => void) | null = null
|
2026-02-01 18:46:35 +00:00
|
|
|
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
private lastMessageTime: number = Date.now()
|
|
|
|
|
private heartbeatInterval = 10000 // Check connection every 10 seconds
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
constructor(url: string = '/ws/db') {
|
|
|
|
|
this.url = url
|
2026-02-01 13:24:03 +00:00
|
|
|
this.setupBrowserEventHandlers()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setupBrowserEventHandlers(): void {
|
|
|
|
|
if (typeof window === 'undefined') return
|
|
|
|
|
|
|
|
|
|
// Handle page visibility changes (tab switching, browser minimizing)
|
|
|
|
|
this.visibilityChangeHandler = () => {
|
|
|
|
|
if (document.visibilityState === 'visible') {
|
|
|
|
|
console.log('[WebSocket] Page became visible, checking connection...')
|
|
|
|
|
// Reconnect if connection was lost while tab was hidden
|
|
|
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
|
|
|
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
|
|
|
|
this.connect().catch(err => {
|
|
|
|
|
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener('visibilitychange', this.visibilityChangeHandler)
|
|
|
|
|
|
|
|
|
|
// Handle network online/offline events
|
|
|
|
|
this.onlineHandler = () => {
|
|
|
|
|
console.log('[WebSocket] Network came online, reconnecting...')
|
|
|
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
|
|
|
this.connect().catch(err => {
|
|
|
|
|
console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('online', this.onlineHandler)
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
connect(): Promise<void> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
// If already connected, resolve immediately
|
|
|
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
console.log('[WebSocket] Already connected, skipping')
|
|
|
|
|
resolve()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If connecting, wait for it
|
|
|
|
|
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
|
|
|
console.log('[WebSocket] Already connecting, waiting...')
|
|
|
|
|
const checkInterval = setInterval(() => {
|
|
|
|
|
if (this.ws) {
|
|
|
|
|
if (this.ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
clearInterval(checkInterval)
|
|
|
|
|
resolve()
|
2026-02-01 13:24:03 +00:00
|
|
|
} else if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
2026-01-24 22:59:20 +00:00
|
|
|
clearInterval(checkInterval)
|
2026-02-01 13:24:03 +00:00
|
|
|
// Connection failed or closing, will be handled by onclose
|
2026-01-24 22:59:20 +00:00
|
|
|
reject(new Error('Connection closed during connect'))
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
clearInterval(checkInterval)
|
|
|
|
|
reject(new Error('WebSocket was cleared'))
|
|
|
|
|
}
|
|
|
|
|
}, 100)
|
|
|
|
|
|
|
|
|
|
// Timeout after 5 seconds
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
clearInterval(checkInterval)
|
|
|
|
|
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
|
|
|
|
|
reject(new Error('Connection timeout'))
|
|
|
|
|
}
|
|
|
|
|
}, 5000)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
// Don't close existing connection if it's still active
|
|
|
|
|
// Only close if it's in CLOSING or CLOSED state
|
|
|
|
|
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
|
2026-01-24 22:59:20 +00:00
|
|
|
this.ws = null
|
2026-02-01 13:24:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we have an active WebSocket, don't create a new one
|
|
|
|
|
if (this.ws) {
|
|
|
|
|
console.log('[WebSocket] Connection exists, reusing it')
|
|
|
|
|
resolve()
|
|
|
|
|
return
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset shouldReconnect flag when explicitly connecting
|
|
|
|
|
this.shouldReconnect = true
|
|
|
|
|
// Reset reconnect attempts only if we're explicitly connecting (not auto-reconnecting)
|
|
|
|
|
// This allows reconnection attempts to continue
|
|
|
|
|
|
|
|
|
|
// In development, Vite proxies /ws to the backend
|
|
|
|
|
// In production, use the same host as the page
|
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
|
|
|
const host = window.location.host
|
|
|
|
|
const wsUrl = `${protocol}//${host}${this.url}`
|
|
|
|
|
|
|
|
|
|
console.log('[WebSocket] Connecting to:', wsUrl)
|
|
|
|
|
|
|
|
|
|
this.ws = new WebSocket(wsUrl)
|
|
|
|
|
|
|
|
|
|
// Timeout handler in case connection hangs
|
|
|
|
|
const connectionTimeout = setTimeout(() => {
|
|
|
|
|
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
|
|
|
console.warn('WebSocket connection timeout, retrying...')
|
|
|
|
|
this.ws.close()
|
|
|
|
|
reject(new Error('Connection timeout'))
|
|
|
|
|
}
|
|
|
|
|
}, 3000) // 3 second timeout
|
|
|
|
|
|
|
|
|
|
this.ws.onopen = () => {
|
|
|
|
|
clearTimeout(connectionTimeout)
|
|
|
|
|
this.reconnectAttempts = 0
|
2026-02-01 18:46:35 +00:00
|
|
|
this.lastMessageTime = Date.now()
|
2026-01-24 22:59:20 +00:00
|
|
|
console.log('[WebSocket] Connected successfully')
|
2026-02-01 13:24:03 +00:00
|
|
|
this.notifyConnectionState(true)
|
2026-02-01 18:46:35 +00:00
|
|
|
this.startHeartbeat()
|
2026-01-24 22:59:20 +00:00
|
|
|
resolve()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ws.onerror = (error) => {
|
|
|
|
|
clearTimeout(connectionTimeout)
|
|
|
|
|
console.error('[WebSocket] Connection error:', error)
|
|
|
|
|
// Don't reject immediately - let onclose handle reconnection
|
|
|
|
|
// This prevents errors from blocking reconnection
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ws.onmessage = (event) => {
|
2026-02-01 18:46:35 +00:00
|
|
|
this.lastMessageTime = Date.now()
|
2026-01-24 22:59:20 +00:00
|
|
|
try {
|
|
|
|
|
const update: Update = JSON.parse(event.data)
|
|
|
|
|
this.callbacks.forEach((callback) => callback(update))
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to parse WebSocket message:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ws.onclose = (event) => {
|
|
|
|
|
clearTimeout(connectionTimeout)
|
2026-02-01 18:46:35 +00:00
|
|
|
this.stopHeartbeat()
|
2026-01-24 22:59:20 +00:00
|
|
|
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
|
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
// Notify connection state changed
|
|
|
|
|
this.notifyConnectionState(false)
|
|
|
|
|
|
2026-01-24 22:59:20 +00:00
|
|
|
// Clear the WebSocket reference
|
|
|
|
|
this.ws = null
|
|
|
|
|
|
|
|
|
|
// Don't reconnect if we explicitly disconnected
|
|
|
|
|
if (!this.shouldReconnect) {
|
|
|
|
|
console.log('[WebSocket] Reconnection disabled')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always try to reconnect unless we've exceeded max attempts
|
|
|
|
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
2026-01-27 23:06:18 +00:00
|
|
|
const isHMR = event.code === 1001
|
2026-02-01 13:24:03 +00:00
|
|
|
const isNormalClosure = event.code === 1000 || event.code === 1001
|
|
|
|
|
const isServiceRestart = event.code === 1012
|
|
|
|
|
|
|
|
|
|
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
|
|
|
|
|
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
|
|
|
|
|
|
|
|
|
|
const delay = needsImmediateReconnect ? 0 :
|
|
|
|
|
(this.reconnectAttempts === 0 ? 100 :
|
|
|
|
|
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
|
|
|
|
|
|
|
|
|
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
// Clear any existing reconnect timer
|
|
|
|
|
if (this.reconnectTimer) {
|
|
|
|
|
clearTimeout(this.reconnectTimer)
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const doReconnect = () => {
|
|
|
|
|
// Check again if we should reconnect (might have been disabled)
|
|
|
|
|
if (!this.shouldReconnect) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
// Don't increment attempts for expected disconnects (HMR, normal closure)
|
|
|
|
|
if (!isHMR && !isNormalClosure) {
|
2026-01-24 22:59:20 +00:00
|
|
|
this.reconnectAttempts++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[WebSocket] Attempting reconnection...')
|
|
|
|
|
this.connect().catch((err) => {
|
|
|
|
|
console.error('[WebSocket] Reconnection failed:', err)
|
|
|
|
|
// onclose will be called again and will retry
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (delay === 0) {
|
2026-02-01 13:24:03 +00:00
|
|
|
// Immediate reconnection
|
2026-01-24 22:59:20 +00:00
|
|
|
doReconnect()
|
|
|
|
|
} else {
|
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
doReconnect()
|
|
|
|
|
}, delay)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
console.warn('[WebSocket] Max reconnection attempts reached')
|
|
|
|
|
this.shouldReconnect = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
subscribe(callback: WebSocketCallback): () => void {
|
|
|
|
|
this.callbacks.add(callback)
|
|
|
|
|
return () => {
|
|
|
|
|
this.callbacks.delete(callback)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
|
|
|
|
this.connectionStateCallbacks.add(callback)
|
|
|
|
|
return () => {
|
|
|
|
|
this.connectionStateCallbacks.delete(callback)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private notifyConnectionState(connected: boolean): void {
|
|
|
|
|
this.connectionStateCallbacks.forEach((callback) => callback(connected))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 18:46:35 +00:00
|
|
|
private startHeartbeat(): void {
|
|
|
|
|
this.stopHeartbeat()
|
|
|
|
|
|
|
|
|
|
this.heartbeatTimer = setInterval(() => {
|
|
|
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
|
|
|
console.warn('[WebSocket] Heartbeat detected closed connection')
|
|
|
|
|
this.stopHeartbeat()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if we've received a message recently
|
|
|
|
|
const timeSinceLastMessage = Date.now() - this.lastMessageTime
|
|
|
|
|
|
|
|
|
|
// If no message for more than 60 seconds, assume connection is stale
|
|
|
|
|
if (timeSinceLastMessage > 60000) {
|
|
|
|
|
console.warn('[WebSocket] No messages for 60s, reconnecting...')
|
|
|
|
|
this.ws.close()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}, this.heartbeatInterval)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private stopHeartbeat(): void {
|
|
|
|
|
if (this.heartbeatTimer) {
|
|
|
|
|
clearInterval(this.heartbeatTimer)
|
|
|
|
|
this.heartbeatTimer = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 22:59:20 +00:00
|
|
|
disconnect(): void {
|
|
|
|
|
this.shouldReconnect = false
|
|
|
|
|
this.reconnectAttempts = 0
|
2026-02-01 18:46:35 +00:00
|
|
|
this.stopHeartbeat()
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
// Clear reconnect timer
|
|
|
|
|
if (this.reconnectTimer) {
|
|
|
|
|
clearTimeout(this.reconnectTimer)
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.ws) {
|
|
|
|
|
// Remove handlers to prevent reconnection
|
|
|
|
|
this.ws.onclose = null
|
|
|
|
|
this.ws.onerror = null
|
|
|
|
|
try {
|
|
|
|
|
this.ws.close()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Ignore errors
|
|
|
|
|
}
|
|
|
|
|
this.ws = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reset(): void {
|
|
|
|
|
this.disconnect()
|
|
|
|
|
this.callbacks.clear()
|
2026-02-01 13:24:03 +00:00
|
|
|
|
|
|
|
|
// Clean up browser event handlers
|
|
|
|
|
if (this.visibilityChangeHandler) {
|
|
|
|
|
document.removeEventListener('visibilitychange', this.visibilityChangeHandler)
|
|
|
|
|
this.visibilityChangeHandler = null
|
|
|
|
|
}
|
|
|
|
|
if (this.onlineHandler) {
|
|
|
|
|
window.removeEventListener('online', this.onlineHandler)
|
|
|
|
|
this.onlineHandler = null
|
|
|
|
|
}
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isConnected(): boolean {
|
|
|
|
|
return this.ws?.readyState === WebSocket.OPEN
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create singleton that persists across HMR
|
|
|
|
|
let wsClientInstance: WebSocketClient | null = null
|
|
|
|
|
|
|
|
|
|
function getWebSocketClient(): WebSocketClient {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
// SSR - create new instance
|
2026-01-24 23:09:46 +00:00
|
|
|
if (!wsClientInstance) {
|
|
|
|
|
wsClientInstance = new WebSocketClient()
|
|
|
|
|
}
|
|
|
|
|
return wsClientInstance
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if we have a persisted instance from HMR
|
2026-03-04 05:23:42 +00:00
|
|
|
const existing = (window as unknown as Record<string, unknown>).__archipelago_ws_client
|
2026-01-24 23:09:46 +00:00
|
|
|
if (existing && existing instanceof WebSocketClient) {
|
2026-01-24 22:59:20 +00:00
|
|
|
// Check if the WebSocket is still valid
|
2026-02-01 06:04:36 +00:00
|
|
|
if (existing.isConnected()) {
|
2026-01-24 22:59:20 +00:00
|
|
|
console.log('[WebSocket] Using existing connected client from HMR')
|
2026-01-24 23:09:46 +00:00
|
|
|
wsClientInstance = existing
|
2026-01-24 22:59:20 +00:00
|
|
|
return existing
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new instance
|
|
|
|
|
if (!wsClientInstance) {
|
|
|
|
|
wsClientInstance = new WebSocketClient()
|
2026-01-24 23:09:46 +00:00
|
|
|
if (typeof window !== 'undefined') {
|
2026-03-04 05:23:42 +00:00
|
|
|
;(window as unknown as Record<string, unknown>).__archipelago_ws_client = wsClientInstance
|
2026-01-24 23:09:46 +00:00
|
|
|
}
|
2026-03-01 17:53:18 +00:00
|
|
|
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return wsClientInstance
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 23:09:46 +00:00
|
|
|
// Lazy initialization - only create when accessed
|
|
|
|
|
let _wsClient: WebSocketClient | null = null
|
|
|
|
|
|
|
|
|
|
export const wsClient: WebSocketClient = (() => {
|
|
|
|
|
if (_wsClient) {
|
|
|
|
|
return _wsClient
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
_wsClient = getWebSocketClient()
|
|
|
|
|
return _wsClient
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[WebSocket] Error initializing client:', error)
|
|
|
|
|
// Fallback to new instance
|
|
|
|
|
_wsClient = new WebSocketClient()
|
|
|
|
|
return _wsClient
|
|
|
|
|
}
|
|
|
|
|
})()
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
// Helper to apply patches to data
|
|
|
|
|
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
|
|
|
|
// Validate patch is an array before applying
|
|
|
|
|
if (!Array.isArray(patch) || patch.length === 0) {
|
|
|
|
|
console.warn('Invalid or empty patch received, returning original data')
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-04 05:23:42 +00:00
|
|
|
const result = applyPatch(data, patch as Operation[], false, false)
|
2026-01-24 22:59:20 +00:00
|
|
|
return result.newDocument as T
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to apply patch:', error, 'Patch:', patch)
|
|
|
|
|
return data // Return original data on error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|