404 lines
11 KiB
TypeScript
404 lines
11 KiB
TypeScript
import { ref, readonly } from 'vue';
|
|
import { defineStore } from 'pinia';
|
|
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('connect', () => config.loadConfig(false));
|
|
|
|
on('connected', () => {
|
|
error.value = null;
|
|
config.loadConfig(false);
|
|
});
|
|
|
|
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>>,
|
|
};
|
|
});
|