ytptube/ui/app/stores/SocketStore.ts
2026-01-22 19:36:20 +03:00

409 lines
11 KiB
TypeScript

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<SocketHandler, SocketHandler>
type KnownEvent = keyof WSEP
export const useSocketStore = defineStore('socket', () => {
const runtimeConfig = useRuntimeConfig()
const config = useConfigStore()
const stateStore = useStateStore()
const toast = useNotification()
const socket = ref<WebSocket | null>(null)
const isConnected = ref<boolean>(false)
const connectionStatus = ref<connectionStatus>('disconnected')
const error = ref<string | null>(null)
const error_count = ref<number>(0)
const wasHidden = ref<boolean>(false)
const reconnectTimeout = ref<NodeJS.Timeout | null>(null)
const manualDisconnect = ref<boolean>(false)
const reconnectAttempts = ref<number>(0)
const handlers = new Map<string, HandlerRegistry>()
const emit = <K extends keyof WebSocketClientEmits>(event: K, data: WebSocketClientEmits[K]): void => {
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
return
}
socket.value.send(JSON.stringify({ event, data }))
}
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void
function on<K extends KnownEvent>(
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<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void
function off<K extends KnownEvent>(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<string>) => {
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<ConfigState>)
})
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<string, unknown>)?.message ?? '')
const extra = (data?.data as Record<string, unknown>)?.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<Ref<connectionStatus>>,
error: readonly(error) as Readonly<Ref<string | null>>,
error_count: readonly(error_count) as Readonly<Ref<number>>,
}
})