import { ref, readonly } from 'vue' import { defineStore } from 'pinia' import type { ConfigState } from "~/types/config"; import type { StoreItem } from "~/types/store"; import type { ConfigUpdatePayload, WebSocketClientEmits, WebSocketEnvelope, WSEP as WSEP } from "~/types/sockets"; export type connectionStatus = 'connected' | 'disconnected' | 'connecting'; type SocketHandler = (...args: unknown[]) => void type HandlerRegistry = Map type KnownEvent = keyof WSEP export const useSocketStore = defineStore('socket', () => { const runtimeConfig = useRuntimeConfig() const config = useConfigStore() const stateStore = useStateStore() const toast = useNotification() const socket = ref(null) const isConnected = ref(false) const connectionStatus = ref('disconnected') const error = ref(null) const error_count = ref(0) const wasHidden = ref(false) const reconnectTimeout = ref(null) const manualDisconnect = ref(false) const reconnectAttempts = ref(0) const handlers = new Map() const emit = (event: K, data: WebSocketClientEmits[K]): void => { if (!socket.value || WebSocket.OPEN !== socket.value.readyState) { return } socket.value.send(JSON.stringify({ event, data })) } function on(event: K, callback: (payload: WSEP[K]) => void): void function on(event: K[], callback: (payload: WSEP[K]) => void): void function on( event: K | K[], callback: (event: K, payload: WSEP[K]) => void, withEvent: true ): void function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void { const events = Array.isArray(event) ? event : [event] events.forEach((eventName) => { if (!handlers.has(eventName)) { handlers.set(eventName, new Map()) } const registry = handlers.get(eventName) as HandlerRegistry const handler = true === withEvent ? (payload: unknown) => callback(eventName, payload) : (payload: unknown) => callback(payload) registry.set(callback, handler) }) } function off(event: K, callback?: (payload: WSEP[K]) => void): void function off(event: K[], callback?: (payload: WSEP[K]) => void): void function off(event: string | string[], callback?: SocketHandler): void function off(event: string | string[], callback?: SocketHandler): void { const events = Array.isArray(event) ? event : [event] events.forEach((eventName) => { const registry = handlers.get(eventName) if (!registry) { return } if (!callback) { registry.clear() handlers.delete(eventName) return } registry.delete(callback) if (0 === registry.size) { handlers.delete(eventName) } }) } const getSessionId = (): string | null => null const dispatch = (eventName: string, payload: unknown): void => { const registry = handlers.get(eventName) if (!registry) { return } registry.forEach((handler) => handler(payload)) } const handleVisibilityChange = () => { if (document.hidden) { wasHidden.value = true return } if (true === wasHidden.value && false === isConnected.value) { if (null !== reconnectTimeout.value) { clearTimeout(reconnectTimeout.value) reconnectTimeout.value = null } reconnectTimeout.value = setTimeout(() => { if (false === isConnected.value) { console.debug('[SocketStore] Page visible after background, reconnecting...') reconnect() } reconnectTimeout.value = null }, 100) } wasHidden.value = false } const setupVisibilityListener = () => { if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', handleVisibilityChange) } } const cleanupVisibilityListener = () => { if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', handleVisibilityChange) } if (null !== reconnectTimeout.value) { clearTimeout(reconnectTimeout.value) reconnectTimeout.value = null } } const scheduleReconnect = () => { if (true === manualDisconnect.value || true === isConnected.value) { return } if (reconnectAttempts.value >= 50) { return } if (null !== reconnectTimeout.value) { return } reconnectTimeout.value = setTimeout(() => { reconnectAttempts.value += 1 reconnectTimeout.value = null connect() }, 5000) } const reconnect = () => { if (true === isConnected.value) { return } connect() connectionStatus.value = 'connecting' } const disconnect = () => { manualDisconnect.value = true if (null === socket.value) { return } socket.value.close() socket.value = null isConnected.value = false connectionStatus.value = 'disconnected' cleanupVisibilityListener() } const buildWsUrl = (): string => { const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '') const wsPath = `${basePath}/ws?_=${Date.now()}` const configuredBase = runtimeConfig.public.wss?.trim() if (configuredBase) { return new URL(wsPath, configuredBase).toString() } const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws' return new URL(wsPath, `${scheme}://${window.location.host}`).toString() } const connect = () => { if (socket.value && WebSocket.OPEN === socket.value.readyState) { return } if (socket.value && WebSocket.CONNECTING === socket.value.readyState) { return } manualDisconnect.value = false connectionStatus.value = 'connecting' socket.value = new WebSocket(buildWsUrl()) if ('development' === runtimeConfig.public?.APP_ENV) { window.ws = socket.value } socket.value.addEventListener('open', () => { isConnected.value = true connectionStatus.value = 'connected' error.value = null error_count.value = 0 reconnectAttempts.value = 0 dispatch('connect', null) }) socket.value.addEventListener('close', () => { isConnected.value = false connectionStatus.value = 'disconnected' error.value = 'Disconnected from server.' dispatch('disconnect', null) scheduleReconnect() }) socket.value.addEventListener('error', () => { isConnected.value = false connectionStatus.value = 'disconnected' error.value = 'Connection error: Unknown error' error_count.value += 1 dispatch('connect_error', { message: 'Unknown error' }) scheduleReconnect() }) socket.value.addEventListener('message', (event: MessageEvent) => { let payload: WebSocketEnvelope | null = null try { payload = JSON.parse(event.data) } catch { return } if (!payload?.event || 'string' != typeof payload.event) { return } let data = payload.data if ('string' === typeof data) { try { data = JSON.parse(data) } catch { data = payload.data } } dispatch(payload.event, data) }) setupVisibilityListener() } on('configuration', (data: WSEP['configuration']) => { config.setAll({ app: data.data.config, presets: data.data.presets, dl_fields: data.data.dl_fields, paused: Boolean(data.data.paused) } as unknown as Partial) }) on('connected', (data: WSEP['connected']) => { if (!data?.data) { return } if (data.data.folders) { config.add('folders', data.data.folders) } if ('number' === typeof data.data.history_count) { stateStore.setHistoryCount(data.data.history_count) } error.value = null }) on('active_queue', (data: WSEP['active_queue']) => { if (!data.data.queue) { return } stateStore.addAll('queue', data.data.queue || {}) }) on('item_added', (data: WSEP['item_added']) => { stateStore.add('queue', data.data._id, data.data) toast.success(`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`) }) on(['log_info', 'log_success', 'log_warning', 'log_error'], (event, data: WSEP['log_info']) => { const message = 'string' === typeof data?.message ? data.message : String((data?.data as Record)?.message ?? '') const extra = (data?.data as Record)?.data || data?.data || {} switch (event) { case 'log_info': toast.info(message, extra) break case 'log_success': toast.success(message, extra) break case 'log_warning': toast.warning(message, extra) break case 'log_error': toast.error(message, extra) break } }, true) on('item_cancelled', (data: WSEP['item_cancelled']) => { const id = data.data._id if (true !== stateStore.has('queue', id)) { return } toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`) if (true === stateStore.has('queue', id)) { stateStore.remove('queue', id) } }) on('item_deleted', (data: WSEP['item_deleted']) => { const id = data.data._id if (true !== stateStore.has('history', id)) { return } stateStore.remove('history', id) }) on('item_updated', (data: WSEP['item_updated']) => { const id = data.data._id if (true === stateStore.has('history', id)) { stateStore.update('history', id, data.data) return } if (true === stateStore.has('queue', id)) { stateStore.update('queue', id, data.data) } }) on('item_moved', (data: WSEP['item_moved']) => { const to = data.data.to const id = data.data.item._id if ('queue' === to) { if (true === stateStore.has('history', id)) { stateStore.remove('history', id) } stateStore.add('queue', id, data.data.item) } if ('history' === to) { if (true === stateStore.has('queue', id)) { stateStore.remove('queue', id) } stateStore.add('history', id, data.data.item) } }) on(['paused', 'resumed'], (event, data: WSEP['paused']) => { const pausedState = Boolean(data.data.paused) config.update('paused', pausedState) if ('resumed' === event) { toast.success('Download queue resumed.') return } toast.warning('Download queue paused.', { timeout: 10000 }) }, true) on('config_update', (data: WSEP['config_update']) => { const configUpdate = data.data as ConfigUpdatePayload if (!configUpdate) { return } config.patch(configUpdate.feature, configUpdate.action, configUpdate.data) }) return { connect, reconnect, disconnect, on, off, emit, isConnected, getSessionId, connectionStatus: readonly(connectionStatus) as Readonly>, error: readonly(error) as Readonly>, error_count: readonly(error_count) as Readonly>, } })