188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import * as api from "@/lib/api";
|
|
import { handleUnauthorized } from "@/lib/queryClient";
|
|
import type { LiveQueryEvent, QueriesPage } from "@/lib/types";
|
|
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
|
|
|
|
export type StreamStatus = "connecting" | "open" | "retrying" | "capped";
|
|
|
|
/** Minimal EventSource surface so tests can inject a fake. */
|
|
export interface EventSourceLike {
|
|
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void;
|
|
close(): void;
|
|
readyState: number;
|
|
}
|
|
|
|
/** `EventSource.CLOSED`: the browser gave up and will not retry. */
|
|
export const EVENT_SOURCE_CLOSED = 2;
|
|
|
|
export type EventSourceFactory = (url: string) => EventSourceLike;
|
|
|
|
export interface LiveQueriesOptions {
|
|
url?: string;
|
|
createEventSource?: EventSourceFactory;
|
|
fetchSince?: (since: number) => Promise<QueriesPage>;
|
|
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
|
|
probeSession?: () => Promise<unknown>;
|
|
}
|
|
|
|
// A transient drop is invisible to EventSource beyond a bare `error` event;
|
|
// this many consecutive errors without an intervening `open` (the browser
|
|
// retries every 3s per the server's `retry: 3000`) stops the stream and
|
|
// surfaces a manual-retry state. A non-200 response instead fails the source
|
|
// permanently after one error event, and is handled by readyState below.
|
|
export const CAP_ERROR_THRESHOLD = 3;
|
|
|
|
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
|
|
const defaultFetchSince = (since: number): Promise<QueriesPage> => api.getQueries({ since, limit: RING_CAPACITY });
|
|
const defaultProbeSession = (): Promise<unknown> => api.getPause();
|
|
|
|
function isUnauthorized(error: unknown): boolean {
|
|
return error instanceof api.ApiError && error.status === 401;
|
|
}
|
|
|
|
export interface LiveQueries {
|
|
/** Newest-first; the freeze-time snapshot while frozen. */
|
|
rows: LiveRow[];
|
|
/** Size of the live buffer, which keeps filling while frozen. */
|
|
liveCount: number;
|
|
status: StreamStatus;
|
|
/** Rows recovered by the reconnect re-sync; null until a re-sync happens or after dismissal. */
|
|
missed: number | null;
|
|
resyncFailed: boolean;
|
|
frozen: boolean;
|
|
toggleFreeze: () => void;
|
|
retry: () => void;
|
|
dismissMissed: () => void;
|
|
}
|
|
|
|
export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
|
const [rows, setRows] = useState<LiveRow[]>([]);
|
|
const [status, setStatus] = useState<StreamStatus>("connecting");
|
|
const [missed, setMissed] = useState<number | null>(null);
|
|
const [resyncFailed, setResyncFailed] = useState(false);
|
|
const [frozen, setFrozen] = useState(false);
|
|
const [frozenRows, setFrozenRows] = useState<LiveRow[]>([]);
|
|
|
|
const bufferRef = useRef<LiveRow[]>([]);
|
|
const keyRef = useRef(0);
|
|
const lastSeenTsRef = useRef<number | null>(null);
|
|
const everOpenRef = useRef(false);
|
|
const errorsRef = useRef(0);
|
|
const esRef = useRef<EventSourceLike | null>(null);
|
|
|
|
const optionsRef = useRef(options);
|
|
optionsRef.current = options;
|
|
|
|
const connect = useCallback(() => {
|
|
esRef.current?.close();
|
|
errorsRef.current = 0;
|
|
setStatus("connecting");
|
|
const opts = optionsRef.current;
|
|
const fetchSince = opts?.fetchSince ?? defaultFetchSince;
|
|
const probeSession = opts?.probeSession ?? defaultProbeSession;
|
|
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
|
|
esRef.current = es;
|
|
|
|
es.addEventListener("open", () => {
|
|
if (esRef.current !== es) return;
|
|
errorsRef.current = 0;
|
|
setStatus("open");
|
|
const since = lastSeenTsRef.current;
|
|
if (everOpenRef.current && since !== null) {
|
|
setResyncFailed(false);
|
|
fetchSince(since).then(
|
|
(page) => {
|
|
if (esRef.current !== es) return;
|
|
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current);
|
|
bufferRef.current = merged.rows;
|
|
setRows(merged.rows);
|
|
setMissed(merged.missed);
|
|
},
|
|
(error: unknown) => {
|
|
if (esRef.current !== es) return;
|
|
if (isUnauthorized(error)) {
|
|
handleUnauthorized(error);
|
|
return;
|
|
}
|
|
setResyncFailed(true);
|
|
},
|
|
);
|
|
}
|
|
everOpenRef.current = true;
|
|
});
|
|
|
|
es.addEventListener("query", (event) => {
|
|
if (esRef.current !== es) return;
|
|
if (typeof event.data !== "string") return;
|
|
let payload: LiveQueryEvent;
|
|
try {
|
|
payload = JSON.parse(event.data) as LiveQueryEvent;
|
|
} catch {
|
|
return;
|
|
}
|
|
lastSeenTsRef.current = payload.ts;
|
|
bufferRef.current = pushRow(bufferRef.current, { ...payload, key: ++keyRef.current });
|
|
setRows(bufferRef.current);
|
|
});
|
|
|
|
const giveUp = () => {
|
|
es.close();
|
|
setStatus("capped");
|
|
// EventSource cannot surface a 401; an expired session looks
|
|
// identical to the cap. Probe once on entering capped so the
|
|
// user lands on login instead of a misleading capped message.
|
|
probeSession().catch(handleUnauthorized);
|
|
};
|
|
|
|
es.addEventListener("error", () => {
|
|
if (esRef.current !== es) return;
|
|
// A 429 or 401 closes the source outright — no retry follows, so
|
|
// the consecutive-error counter would never reach its threshold.
|
|
if (es.readyState === EVENT_SOURCE_CLOSED) {
|
|
errorsRef.current = CAP_ERROR_THRESHOLD;
|
|
giveUp();
|
|
return;
|
|
}
|
|
errorsRef.current += 1;
|
|
if (errorsRef.current >= CAP_ERROR_THRESHOLD) {
|
|
giveUp();
|
|
} else {
|
|
setStatus("retrying");
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
connect();
|
|
return () => {
|
|
esRef.current?.close();
|
|
esRef.current = null;
|
|
};
|
|
}, [connect]);
|
|
|
|
const toggleFreeze = () => {
|
|
if (frozen) {
|
|
setFrozen(false);
|
|
} else {
|
|
setFrozen(true);
|
|
setFrozenRows(bufferRef.current);
|
|
}
|
|
};
|
|
|
|
return {
|
|
rows: frozen ? frozenRows : rows,
|
|
liveCount: rows.length,
|
|
status,
|
|
missed,
|
|
resyncFailed,
|
|
frozen,
|
|
toggleFreeze,
|
|
retry: connect,
|
|
dismissMissed: () => {
|
|
setMissed(null);
|
|
setResyncFailed(false);
|
|
},
|
|
};
|
|
}
|