148 lines
4.2 KiB
TypeScript
148 lines
4.2 KiB
TypeScript
import type { QueryKey } from '@tanstack/react-query';
|
|
import {
|
|
DashboardEventSchema,
|
|
type DashboardEvent,
|
|
} from '../../../../../packages/api-contracts/src/index.js';
|
|
|
|
export const DASHBOARD_EVENT_TYPES = [
|
|
'bot.status.changed',
|
|
'message.sent',
|
|
'message.failed',
|
|
'conversation.updated',
|
|
'reply.received',
|
|
'sync.started',
|
|
'sync.progress',
|
|
'sync.completed',
|
|
'sync.failed',
|
|
'rate-limit.changed',
|
|
'reddit.session.changed',
|
|
'log.created',
|
|
] as const;
|
|
|
|
export interface DashboardEventSource {
|
|
addEventListener(type: string, listener: (event: unknown) => void): void;
|
|
close(): void;
|
|
}
|
|
|
|
export interface RealtimeConnectionSnapshot {
|
|
status: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
|
isStale: boolean;
|
|
lastEventId?: string;
|
|
}
|
|
|
|
export interface DashboardRealtimeClient {
|
|
start(): void;
|
|
stop(): void;
|
|
getSnapshot(): RealtimeConnectionSnapshot;
|
|
}
|
|
|
|
export interface DashboardRealtimeClientOptions {
|
|
url?: string;
|
|
sourceFactory(url: string): DashboardEventSource;
|
|
onEvent(event: DashboardEvent): void;
|
|
onConnected?(context: { isReconnect: boolean }): void;
|
|
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
|
|
onInvalidEvent?(value: unknown): void;
|
|
}
|
|
|
|
function eventData(value: unknown): string | undefined {
|
|
if (typeof value !== 'object' || value === null || !('data' in value)) return undefined;
|
|
return typeof value.data === 'string' ? value.data : undefined;
|
|
}
|
|
|
|
export function createDashboardRealtimeClient(
|
|
options: DashboardRealtimeClientOptions,
|
|
): DashboardRealtimeClient {
|
|
let source: DashboardEventSource | undefined;
|
|
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
|
|
let hasConnected = false;
|
|
|
|
function update(next: RealtimeConnectionSnapshot): void {
|
|
snapshot = next;
|
|
options.onStateChange?.(snapshot);
|
|
}
|
|
|
|
function receive(value: unknown): void {
|
|
const data = eventData(value);
|
|
if (data === undefined) {
|
|
options.onInvalidEvent?.(value);
|
|
return;
|
|
}
|
|
let decoded: unknown;
|
|
try {
|
|
decoded = JSON.parse(data);
|
|
} catch {
|
|
options.onInvalidEvent?.(data);
|
|
return;
|
|
}
|
|
const result = DashboardEventSchema.safeParse(decoded);
|
|
if (!result.success) {
|
|
options.onInvalidEvent?.(decoded);
|
|
return;
|
|
}
|
|
snapshot = { ...snapshot, lastEventId: result.data.id };
|
|
options.onEvent(result.data);
|
|
}
|
|
|
|
return {
|
|
start() {
|
|
if (source !== undefined) return;
|
|
update({ ...snapshot, status: 'connecting', isStale: true });
|
|
source = options.sourceFactory(options.url ?? '/api/events/stream');
|
|
source.addEventListener('open', () => {
|
|
update({ ...snapshot, status: 'connected', isStale: false });
|
|
options.onConnected?.({ isReconnect: hasConnected });
|
|
hasConnected = true;
|
|
});
|
|
source.addEventListener('error', () => {
|
|
update({ ...snapshot, status: 'reconnecting', isStale: true });
|
|
});
|
|
for (const type of DASHBOARD_EVENT_TYPES) source.addEventListener(type, receive);
|
|
},
|
|
stop() {
|
|
source?.close();
|
|
source = undefined;
|
|
update({ ...snapshot, status: 'closed', isStale: true });
|
|
},
|
|
getSnapshot() {
|
|
return snapshot;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function queryKeysForDashboardEvent(event: DashboardEvent): readonly QueryKey[] {
|
|
switch (event.type) {
|
|
case 'bot.status.changed':
|
|
return [['dashboard', 'stats']];
|
|
case 'rate-limit.changed':
|
|
return [['dashboard', 'stats'], ['dashboard', 'timers']];
|
|
case 'message.sent':
|
|
case 'message.failed':
|
|
return [['conversations'], ['performance']];
|
|
case 'conversation.updated':
|
|
case 'reply.received':
|
|
return [['conversations'], ['operations', 'activity']];
|
|
case 'sync.started':
|
|
case 'sync.progress':
|
|
case 'sync.completed':
|
|
case 'sync.failed':
|
|
return [['operations', 'sync-status'], ['conversations'], ['media']];
|
|
case 'reddit.session.changed':
|
|
return [['dashboard', 'login', 'status']];
|
|
case 'log.created':
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function createBrowserEventSource(url: string): DashboardEventSource {
|
|
const source = new EventSource(url);
|
|
return {
|
|
addEventListener(type, listener) {
|
|
source.addEventListener(type, (event) => listener(event));
|
|
},
|
|
close() {
|
|
source.close();
|
|
},
|
|
};
|
|
}
|