373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
fetchDevServerLogHistory,
|
|
getDevServerLogsStreamUrl,
|
|
type DevServerLogHistoryEntry,
|
|
} from "../api";
|
|
import { subscribeSse } from "../sse-bus";
|
|
|
|
export interface DevServerLogEntry {
|
|
id: number;
|
|
text: string;
|
|
stream: "stdout" | "stderr";
|
|
timestamp: string;
|
|
}
|
|
|
|
export const MAX_LOG_ENTRIES = 500;
|
|
|
|
const INITIAL_LOAD_LIMIT = 100;
|
|
|
|
function capLogEntries(entries: DevServerLogEntry[]): DevServerLogEntry[] {
|
|
return entries.length > MAX_LOG_ENTRIES ? entries.slice(-MAX_LOG_ENTRIES) : entries;
|
|
}
|
|
|
|
function normalizeStream(stream: unknown): "stdout" | "stderr" {
|
|
return stream === "stderr" ? "stderr" : "stdout";
|
|
}
|
|
|
|
function parseEventData<T>(event: MessageEvent<string>): T | null {
|
|
try {
|
|
return JSON.parse(event.data) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeHistoryEntry(
|
|
line: DevServerLogHistoryEntry,
|
|
fallbackId: number,
|
|
): DevServerLogEntry {
|
|
return {
|
|
id: typeof line.id === "number" && Number.isFinite(line.id) ? line.id : fallbackId,
|
|
text: typeof line.text === "string" ? line.text : "",
|
|
stream: normalizeStream(line.stream),
|
|
timestamp: typeof line.timestamp === "string" ? line.timestamp : "",
|
|
};
|
|
}
|
|
|
|
function normalizeLegacyHistoryLine(line: string, fallbackId: number): DevServerLogEntry {
|
|
return {
|
|
id: fallbackId,
|
|
text: line,
|
|
stream: "stdout",
|
|
timestamp: "",
|
|
};
|
|
}
|
|
|
|
interface IncomingLogPayload {
|
|
id?: number;
|
|
text?: string;
|
|
line?: string;
|
|
timestamp?: string;
|
|
stream?: "stdout" | "stderr";
|
|
}
|
|
|
|
function mergeUniqueEntries(prev: DevServerLogEntry[], incoming: DevServerLogEntry[]): DevServerLogEntry[] {
|
|
if (incoming.length === 0) {
|
|
return prev;
|
|
}
|
|
|
|
const merged = [...prev];
|
|
const seenById = new Set(prev.map((entry) => entry.id));
|
|
|
|
for (const entry of incoming) {
|
|
if (seenById.has(entry.id)) {
|
|
continue;
|
|
}
|
|
seenById.add(entry.id);
|
|
merged.push(entry);
|
|
}
|
|
|
|
merged.sort((a, b) => a.id - b.id);
|
|
return capLogEntries(merged);
|
|
}
|
|
|
|
export function useDevServerLogs(projectId: string | undefined, enabled: boolean) {
|
|
const [entries, setEntries] = useState<DevServerLogEntry[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [total, setTotal] = useState<number | null>(null);
|
|
|
|
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
const cancelledRef = useRef(false);
|
|
const projectContextVersionRef = useRef(0);
|
|
const requestVersionRef = useRef(0);
|
|
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
|
const previousEnabledRef = useRef(enabled);
|
|
const lastSeenEventIdRef = useRef(0);
|
|
const nextSyntheticIdRef = useRef(1);
|
|
|
|
const contextChanged =
|
|
previousProjectIdRef.current !== projectId ||
|
|
previousEnabledRef.current !== enabled;
|
|
|
|
if (contextChanged) {
|
|
previousProjectIdRef.current = projectId;
|
|
previousEnabledRef.current = enabled;
|
|
projectContextVersionRef.current++;
|
|
cancelledRef.current = true;
|
|
lastSeenEventIdRef.current = 0;
|
|
nextSyntheticIdRef.current = 1;
|
|
|
|
setEntries([]);
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
setHasMore(false);
|
|
setTotal(null);
|
|
|
|
if (unsubscribeRef.current) {
|
|
unsubscribeRef.current();
|
|
unsubscribeRef.current = null;
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
if (unsubscribeRef.current) {
|
|
unsubscribeRef.current();
|
|
unsubscribeRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const requestVersion = ++requestVersionRef.current;
|
|
cancelledRef.current = false;
|
|
|
|
setLoading(true);
|
|
|
|
const applyHistory = (historyLines: DevServerLogEntry[], totalLines: number | null) => {
|
|
const normalized = capLogEntries(historyLines);
|
|
setEntries(normalized);
|
|
setTotal(totalLines);
|
|
setHasMore(totalLines !== null ? totalLines > normalized.length : false);
|
|
|
|
const maxId = normalized.length > 0 ? normalized[normalized.length - 1]!.id : 0;
|
|
lastSeenEventIdRef.current = maxId;
|
|
nextSyntheticIdRef.current = maxId + 1;
|
|
};
|
|
|
|
const applyHistoryFromSse = (payload: unknown) => {
|
|
if (cancelledRef.current || projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
if (!payload || typeof payload !== "object") {
|
|
return;
|
|
}
|
|
|
|
const linesRaw = (payload as { lines?: unknown }).lines;
|
|
if (!Array.isArray(linesRaw)) {
|
|
return;
|
|
}
|
|
|
|
if (linesRaw.length > 0 && typeof linesRaw[0] === "string") {
|
|
const normalized = linesRaw
|
|
.filter((line): line is string => typeof line === "string")
|
|
.map((line, index) => normalizeLegacyHistoryLine(line, index + 1));
|
|
applyHistory(normalized, normalized.length);
|
|
return;
|
|
}
|
|
|
|
const structured = linesRaw
|
|
.filter((line): line is DevServerLogHistoryEntry => Boolean(line) && typeof line === "object")
|
|
.map((line, index) => normalizeHistoryEntry(line, index + 1));
|
|
applyHistory(structured, structured.length);
|
|
};
|
|
|
|
const handleLiveEvent = (payload: IncomingLogPayload) => {
|
|
if (cancelledRef.current || projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
const text = typeof payload.text === "string"
|
|
? payload.text
|
|
: (typeof payload.line === "string" ? payload.line : null);
|
|
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
const fallbackId = nextSyntheticIdRef.current;
|
|
const id = typeof payload.id === "number" && Number.isFinite(payload.id)
|
|
? payload.id
|
|
: fallbackId;
|
|
|
|
const entry: DevServerLogEntry = {
|
|
id,
|
|
text,
|
|
stream: normalizeStream(payload.stream),
|
|
timestamp: typeof payload.timestamp === "string" ? payload.timestamp : "",
|
|
};
|
|
|
|
lastSeenEventIdRef.current = Math.max(lastSeenEventIdRef.current, id);
|
|
nextSyntheticIdRef.current = Math.max(nextSyntheticIdRef.current, id + 1);
|
|
|
|
setEntries((prev) => mergeUniqueEntries(prev, [entry]));
|
|
setTotal((prev) => {
|
|
if (prev === null) {
|
|
return prev;
|
|
}
|
|
return Math.max(prev + 1, entry.id);
|
|
});
|
|
};
|
|
|
|
async function init(): Promise<void> {
|
|
try {
|
|
const result = await fetchDevServerLogHistory({ maxLines: INITIAL_LOAD_LIMIT }, projectId);
|
|
|
|
if (cancelledRef.current ||
|
|
projectContextVersionRef.current !== contextVersionAtStart ||
|
|
requestVersionRef.current !== requestVersion) {
|
|
return;
|
|
}
|
|
|
|
const normalized = result.lines.map((line, index) => normalizeHistoryEntry(line, index + 1));
|
|
applyHistory(normalized, result.totalLines);
|
|
} catch {
|
|
if (cancelledRef.current ||
|
|
projectContextVersionRef.current !== contextVersionAtStart ||
|
|
requestVersionRef.current !== requestVersion) {
|
|
return;
|
|
}
|
|
|
|
applyHistory([], null);
|
|
} finally {
|
|
if (!cancelledRef.current &&
|
|
projectContextVersionRef.current === contextVersionAtStart &&
|
|
requestVersionRef.current === requestVersion) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const streamUrl = getDevServerLogsStreamUrl(projectId);
|
|
unsubscribeRef.current = subscribeSse(streamUrl, {
|
|
events: {
|
|
"dev-server:log": (event) => {
|
|
const parsed = parseEventData<IncomingLogPayload>(event);
|
|
if (!parsed) {
|
|
return;
|
|
}
|
|
handleLiveEvent(parsed);
|
|
},
|
|
log: (event) => {
|
|
const parsed = parseEventData<IncomingLogPayload>(event);
|
|
if (!parsed) {
|
|
return;
|
|
}
|
|
handleLiveEvent(parsed);
|
|
},
|
|
history: (event) => {
|
|
const parsed = parseEventData<{ lines?: unknown }>(event);
|
|
applyHistoryFromSse(parsed);
|
|
},
|
|
"dev-server:history": (event) => {
|
|
const parsed = parseEventData<{ lines?: unknown }>(event);
|
|
applyHistoryFromSse(parsed);
|
|
},
|
|
},
|
|
onReconnect: () => {
|
|
if (cancelledRef.current || projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
void fetchDevServerLogHistory(
|
|
{
|
|
lastEventId: lastSeenEventIdRef.current,
|
|
maxLines: 50,
|
|
},
|
|
projectId,
|
|
).then((result) => {
|
|
if (cancelledRef.current || projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
const normalized = result.lines.map((line, index) =>
|
|
normalizeHistoryEntry(line, nextSyntheticIdRef.current + index));
|
|
|
|
if (normalized.length > 0) {
|
|
const maxId = normalized[normalized.length - 1]!.id;
|
|
lastSeenEventIdRef.current = Math.max(lastSeenEventIdRef.current, maxId);
|
|
nextSyntheticIdRef.current = Math.max(nextSyntheticIdRef.current, maxId + 1);
|
|
}
|
|
|
|
setEntries((prev) => mergeUniqueEntries(prev, normalized));
|
|
setTotal(result.totalLines);
|
|
}).catch(() => {
|
|
// Keep stream alive and fail silently.
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
void init();
|
|
|
|
return () => {
|
|
cancelledRef.current = true;
|
|
if (unsubscribeRef.current) {
|
|
unsubscribeRef.current();
|
|
unsubscribeRef.current = null;
|
|
}
|
|
};
|
|
}, [enabled, projectId]);
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (!enabled || loadingMore) {
|
|
return;
|
|
}
|
|
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const currentEntriesCount = entries.length;
|
|
|
|
setLoadingMore(true);
|
|
|
|
try {
|
|
const result = await fetchDevServerLogHistory(
|
|
{
|
|
maxLines: INITIAL_LOAD_LIMIT,
|
|
offset: currentEntriesCount,
|
|
},
|
|
projectId,
|
|
);
|
|
|
|
if (cancelledRef.current || projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
const normalized = result.lines.map((line, index) => normalizeHistoryEntry(line, index + 1));
|
|
|
|
setEntries((prev) => mergeUniqueEntries(normalized, prev));
|
|
setHasMore(result.totalLines > currentEntriesCount + normalized.length);
|
|
setTotal(result.totalLines);
|
|
} catch {
|
|
// Silent by design: pagination failures should not block live logs.
|
|
} finally {
|
|
setLoadingMore(false);
|
|
}
|
|
}, [enabled, entries.length, loadingMore, projectId]);
|
|
|
|
const clear = useCallback(() => {
|
|
setEntries([]);
|
|
}, []);
|
|
|
|
// Track streaming state: true when SSE subscription is active
|
|
const isStreaming = unsubscribeRef.current !== null && !loading && !cancelledRef.current;
|
|
|
|
return {
|
|
entries,
|
|
loading,
|
|
loadingMore,
|
|
hasMore,
|
|
total,
|
|
loadMore,
|
|
clear,
|
|
// New simplified interface per Step 2 requirements
|
|
logs: entries, // Alias for backward compatibility
|
|
isStreaming, // Track SSE subscription state
|
|
clearLogs: clear, // Alias for clear
|
|
};
|
|
}
|
|
|
|
export { capLogEntries };
|