import { useEffect, useRef, useState, useCallback } from "react"; import { createEventSource, probeEventStreamStatus } from "../api/events"; import type { InstanceEventPayload } from "../types/events"; export interface UseEventsReturn { events: InstanceEventPayload[]; connected: boolean; reconnectCount: number; error: Error | null; } const MAX_DELAY = 30000; const BASE_DELAY = 1000; const LIFECYCLE_EVENT_TYPES = [ "instance.created", "instance.started", "instance.stopped", "instance.restarted", "instance.deleted", "instance.error", "instance.health_changed", ] as const; export function useEvents(): UseEventsReturn { const [events, setEvents] = useState([]); const [connected, setConnected] = useState(false); const [reconnectCount, setReconnectCount] = useState(0); const [error, setError] = useState(null); const reconnectAttemptsRef = useRef(0); const esRef = useRef(null); const timeoutRef = useRef | null>(null); const isMountedRef = useRef(true); const connect = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } const es = createEventSource(); esRef.current = es; es.onopen = () => { if (!isMountedRef.current) return; setConnected(true); setError(null); reconnectAttemptsRef.current = 0; setReconnectCount(0); }; const handleEventMessage = (e: MessageEvent) => { if (!isMountedRef.current) return; try { const payload: InstanceEventPayload = JSON.parse(e.data); setEvents((prev) => [...prev, payload]); } catch { // ignore malformed events } }; es.addEventListener("message", handleEventMessage); for (const eventType of LIFECYCLE_EVENT_TYPES) { es.addEventListener(eventType, handleEventMessage); } es.onerror = () => { if (!isMountedRef.current) return; setConnected(false); es.close(); esRef.current = null; const attempts = reconnectAttemptsRef.current; const delay = Math.min(MAX_DELAY, BASE_DELAY * Math.pow(2, attempts)) * (0.8 + Math.random() * 0.4); reconnectAttemptsRef.current = attempts + 1; setReconnectCount(reconnectAttemptsRef.current); timeoutRef.current = setTimeout(async () => { if (!isMountedRef.current) return; const status = await probeEventStreamStatus(); if (status === 401) { const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; window.location.assign(`${baseUrl}/auth/login`); return; } if (status === 429) { const penaltyDelay = delay + 5000; timeoutRef.current = setTimeout(() => { if (isMountedRef.current) { connect(); } }, penaltyDelay); return; } connect(); }, delay); }; }, []); useEffect(() => { isMountedRef.current = true; connect(); return () => { isMountedRef.current = false; if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } if (esRef.current) { esRef.current.close(); esRef.current = null; } }; }, [connect]); return { events, connected, reconnectCount, error }; }