Files
claw/third_party/zeroclaw/web/src/hooks/useSSE.ts

125 lines
3.3 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { SSEClient, type SSEClientOptions } from '../lib/sse';
import type { SSEEvent } from '../types/api';
export type SSEConnectionStatus = 'disconnected' | 'connecting' | 'connected';
export interface UseSSEResult {
/** Array of all events received during this session. */
events: SSEEvent[];
/** Current connection status. */
status: SSEConnectionStatus;
/** Manually connect (called automatically on mount). */
connect: () => void;
/** Manually disconnect. */
disconnect: () => void;
/** Clear the event history. */
clearEvents: () => void;
}
export interface UseSSEOptions extends SSEClientOptions {
/** If false, do not connect automatically on mount. Default true. */
autoConnect?: boolean;
/** Maximum number of events to keep in the buffer. Default 500. */
maxEvents?: number;
/** Optional filter: only keep events whose type matches. */
filterTypes?: string[];
}
/**
* React hook that wraps the SSEClient for live event streaming.
*
* Connects on mount (unless `autoConnect` is false), accumulates incoming
* events, and cleans up on unmount.
*/
export function useSSE(options: UseSSEOptions = {}): UseSSEResult {
const {
autoConnect = true,
maxEvents = 500,
filterTypes,
...sseOptions
} = options;
const clientRef = useRef<SSEClient | null>(null);
const [status, setStatus] = useState<SSEConnectionStatus>('disconnected');
const [events, setEvents] = useState<SSEEvent[]>([]);
// Keep filter in a ref so the callback doesn't need to be recreated
const filterRef = useRef(filterTypes);
filterRef.current = filterTypes;
const maxRef = useRef(maxEvents);
maxRef.current = maxEvents;
// Stable reference to the client across renders
const getClient = useCallback((): SSEClient => {
if (!clientRef.current) {
clientRef.current = new SSEClient(sseOptions);
}
return clientRef.current;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Setup handlers and optionally connect on mount
useEffect(() => {
const client = getClient();
client.onConnect = () => {
setStatus('connected');
};
client.onEvent = (event: SSEEvent) => {
// Apply type filter if configured
if (filterRef.current && filterRef.current.length > 0) {
if (!filterRef.current.includes(event.type)) return;
}
setEvents((prev) => {
const next = [...prev, event];
// Trim to max buffer size
if (next.length > maxRef.current) {
return next.slice(next.length - maxRef.current);
}
return next;
});
};
client.onError = () => {
setStatus('disconnected');
};
if (autoConnect) {
setStatus('connecting');
client.connect();
}
return () => {
client.disconnect();
clientRef.current = null;
};
}, [getClient, autoConnect]);
const connect = useCallback(() => {
const client = getClient();
setStatus('connecting');
client.connect();
}, [getClient]);
const disconnect = useCallback(() => {
const client = getClient();
client.disconnect();
setStatus('disconnected');
}, [getClient]);
const clearEvents = useCallback(() => {
setEvents([]);
}, []);
return {
events,
status,
connect,
disconnect,
clearEvents,
};
}