import { io, Socket } from 'socket.io-client' import { useRedisStateStore } from './redis-state.store' import { useSettingsStore } from './settings.store' import { useI18nStore } from './i18n.store' import { notify } from './notification' type Callback = (data: any) => void type VoidCallback = () => void // Event bus for components to subscribe to socket events const listeners: Record> = {} function emit(event: string, data?: any) { listeners[event]?.forEach(cb => cb(data)) } export function onSocketEvent(event: string, cb: Callback): VoidCallback { if (!listeners[event]) listeners[event] = new Set() listeners[event].add(cb) return () => { listeners[event].delete(cb) } } // --- Singleton Socket.IO client --- let client: Socket | null = null let reconnect = false let connectErrorWas = false let disconnected = false let authBlocked = false /** * CRITICAL: Do NOT call getClient() at module level. * Pinia stores are not available until app.use(pinia). * Use ensureConnected() to lazily initialize the socket. */ export function getClient(): Socket | null { return client } export function ensureConnected(): Socket { if (client) return client const redisState = useRedisStateStore() const settingsStore = useSettingsStore() const i18nStore = useI18nStore() const ioOptions: any = { rejectUnauthorized: false, path: '/socket.io', secure: true, reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 5000, } if ((globalThis as any).p3xrDevMode === true) { ioOptions.transports = ['websocket'] } // Include auth token if stored (JWT login) const authToken = localStorage.getItem('p3xr-auth-token') if (authToken) { ioOptions.auth = { token: authToken } } client = io(redisState.apiHost, ioOptions) client.on('connect', () => { if (disconnected || connectErrorWas) { console.log('p3xr-socket RE-connected', client!.id) notify(i18nStore.strings?.title?.name, i18nStore.strings?.status?.connectionRestored) disconnected = false connectErrorWas = false location.reload() return } if (reconnect) { console.log('p3xr-socket RE-connected', client!.id) } else { console.log('p3xr-socket connected', client!.id) } reconnect = true }) client.on('disconnect', () => { if (authBlocked) return disconnected = true emit('disconnect') }) client.on('error', (error: any) => { handleSocketError(error) }) client.on('connect_error', (error: any) => { if (error?.message === 'auth_required') { authBlocked = true client!.disconnect() emit('auth_required') return } handleSocketError(error) }) client.on('connections', (data: any) => { if (data.status === 'error') { redisState.resetConnections() return } redisState.connections = data.connections emit('connections', data) }) client.on('redis-disconnected', (data: any) => { const currentConn = redisState.connection if (currentConn !== undefined && currentConn.id === data.connectionId) { redisState.monitor = false redisState.connection = undefined if (data.status === 'error') { const strings = i18nStore.strings const fn = strings?.status?.redisDisconnected const msg = typeof fn === 'function' ? fn(data) : 'Redis disconnected' showToast(msg) notify(strings?.title?.name, msg) } else if (data.status === 'code') { const strings = i18nStore.strings const codes = strings?.code ?? {} const msg = codes[data.code] ?? `unknown redis disconnect code: ${data.code}` showToast(msg) notify(strings?.title?.name, msg) } emit('redis-disconnected', data) request({ action: 'connection/trigger-disconnect', enableResponse: false }).catch(() => {}) } }) client.on('redis-status', (data: any) => { redisState.redisConnections = data.redisConnections emit('redis-status', data) }) let receivedVersion = false client.on('configuration', (data: any) => { const version = data.snapshot === true ? 'SNAPSHOT' : 'v' + data.version redisState.cfg = data redisState.version = version if (!receivedVersion && !data.snapshot) { receivedVersion = true try { const ga = settingsStore.googleAnalytics ;(window as any).gtag?.('config', ga, { page_path: '/version/' + version }) } catch {} } emit('configuration', data) }) return client } function handleSocketError(error: any) { if (!connectErrorWas) { connectErrorWas = true emit('socket-error', error) } } function showToast(message: string) { try { const snackBar = (globalThis as any).__p3xr_snackbar if (snackBar) { const ref = snackBar.open(message, 'x', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom', }) ref.onAction().subscribe(() => ref.dismiss()) } } catch {} } // --- Request API --- export function request(options: { action: string payload?: any enableResponse?: boolean }): Promise { const socket = ensureConnected() const settingsStore = useSettingsStore() if (!options.payload) { options.payload = {} } options.payload.maxKeys = settingsStore.maxKeys const enableResponse = options.enableResponse !== false if (!enableResponse) { socket.emit('p3xr-request', options) return Promise.resolve() } return new Promise((resolve, reject) => { const requestId = settingsStore.generateId() ;(options as any).requestId = requestId const responseEvent = `p3xr-response-${requestId}` let timeout: ReturnType const response = (data: any) => { clearTimeout(timeout) socket.off(responseEvent) if (data?.status === 'ok') { resolve(data) } else { let errMsg = 'Unknown error' try { const err = data?.error if (typeof err === 'string') errMsg = err else if (err?.message) errMsg = err.message else if (err !== undefined && err !== null) errMsg = String(err) } catch {} reject(new Error(errMsg)) } } timeout = setTimeout(() => { socket.off(responseEvent, response) const i18nStore = useI18nStore() const strings = i18nStore.strings const fn = strings?.label?.socketIoTimeout const msg = typeof fn === 'function' ? fn({ timeout: settingsStore.socketTimeout }) : `Socket.IO timeout (${settingsStore.socketTimeout}ms)` reject(new Error(msg)) }, settingsStore.socketTimeout) socket.on(responseEvent, response) socket.emit('p3xr-request', options) }) }