Files
fusion/packages/dashboard/app/hooks/useDevServer.ts

350 lines
9.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchDevServerCandidates,
fetchDevServerStatus,
getDevServerLogsStreamUrl,
restartDevServer,
setDevServerPreviewUrl,
startDevServer,
stopDevServer,
type DevServerCandidate,
type DevServerState,
} from "../api";
const POLL_INTERVAL_MS = 3000;
const MAX_LOG_LINES = 500;
function normalizeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function normalizeLines(lines: string[]): string[] {
if (lines.length <= MAX_LOG_LINES) {
return lines;
}
return lines.slice(-MAX_LOG_LINES);
}
function appendLine(lines: string[], line: string): string[] {
return normalizeLines([...lines, line]);
}
function parseEventData<T>(event: MessageEvent<string>): T | null {
try {
return JSON.parse(event.data) as T;
} catch {
return null;
}
}
export interface UseDevServerReturn {
candidates: DevServerCandidate[];
serverState: DevServerState | null;
logs: string[];
start: (arg: DevServerCandidate | { command: string; scriptName: string; cwd?: string }) => Promise<void>;
stop: () => Promise<void>;
restart: () => Promise<void>;
setPreviewUrl: (url: string | null) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useDevServer(projectId?: string): UseDevServerReturn {
const [candidates, setCandidates] = useState<DevServerCandidate[]>([]);
const [serverState, setServerState] = useState<DevServerState | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const pollingIntervalRef = useRef<number | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const contextVersionRef = useRef(0);
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current !== null) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}, []);
const closeEventSource = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, []);
const applyServerState = useCallback((nextState: DevServerState) => {
setServerState(nextState);
setLogs((prev) => {
if (!Array.isArray(nextState.logs)) {
return prev;
}
if (prev.length === nextState.logs.length && prev.every((line, index) => line === nextState.logs[index])) {
return prev;
}
return normalizeLines(nextState.logs);
});
}, []);
const pollStatus = useCallback(async () => {
const versionAtStart = contextVersionRef.current;
try {
const nextState = await fetchDevServerStatus(projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyServerState(nextState);
setError(null);
} catch (pollError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(pollError));
stopPolling();
}
}, [applyServerState, projectId, stopPolling]);
useEffect(() => {
const versionAtStart = contextVersionRef.current + 1;
contextVersionRef.current = versionAtStart;
setCandidates([]);
setServerState(null);
setLogs([]);
setLoading(true);
setError(null);
stopPolling();
closeEventSource();
void Promise.allSettled([
fetchDevServerCandidates(projectId),
fetchDevServerStatus(projectId),
]).then((results) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const [candidatesResult, statusResult] = results;
let nextError: string | null = null;
if (candidatesResult.status === "fulfilled") {
setCandidates(candidatesResult.value);
} else {
nextError = normalizeError(candidatesResult.reason);
}
if (statusResult.status === "fulfilled") {
applyServerState(statusResult.value);
} else {
nextError ??= normalizeError(statusResult.reason);
}
setError(nextError);
setLoading(false);
});
return () => {
stopPolling();
closeEventSource();
};
}, [applyServerState, closeEventSource, projectId, stopPolling]);
useEffect(() => {
const status = serverState?.status;
const shouldPoll = status === "starting" || status === "running";
if (!shouldPoll) {
stopPolling();
return;
}
if (pollingIntervalRef.current !== null) {
return;
}
pollingIntervalRef.current = window.setInterval(() => {
void pollStatus();
}, POLL_INTERVAL_MS);
return () => {
stopPolling();
};
}, [pollStatus, serverState?.status, stopPolling]);
useEffect(() => {
const versionAtStart = contextVersionRef.current;
const eventSource = new EventSource(getDevServerLogsStreamUrl(projectId));
eventSourceRef.current = eventSource;
const handleHistory = (event: MessageEvent<string>) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseEventData<{ lines?: string[] }>(event);
if (!payload || !Array.isArray(payload.lines)) {
return;
}
setLogs(normalizeLines(payload.lines.filter((line): line is string => typeof line === "string")));
};
const handleLog = (event: MessageEvent<string>) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const payload = parseEventData<{ line?: string }>(event);
const line = typeof payload?.line === "string" ? payload.line : event.data;
setLogs((prev) => appendLine(prev, line));
};
const handleTerminalStateEvent = () => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
void pollStatus();
};
const handleError = () => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
eventSource.close();
eventSourceRef.current = null;
};
eventSource.addEventListener("history", handleHistory);
eventSource.addEventListener("log", handleLog);
eventSource.addEventListener("stopped", handleTerminalStateEvent);
eventSource.addEventListener("failed", handleTerminalStateEvent);
eventSource.addEventListener("error", handleError);
return () => {
eventSource.removeEventListener("history", handleHistory);
eventSource.removeEventListener("log", handleLog);
eventSource.removeEventListener("stopped", handleTerminalStateEvent);
eventSource.removeEventListener("failed", handleTerminalStateEvent);
eventSource.removeEventListener("error", handleError);
eventSource.close();
if (eventSourceRef.current === eventSource) {
eventSourceRef.current = null;
}
};
}, [pollStatus, projectId]);
const start = useCallback(async (arg: DevServerCandidate | { command: string; scriptName: string; cwd?: string }) => {
const versionAtStart = contextVersionRef.current;
const payload = "label" in arg
? {
command: arg.command,
scriptName: arg.scriptName,
cwd: arg.cwd,
}
: {
command: arg.command,
scriptName: arg.scriptName,
cwd: arg.cwd,
};
setError(null);
try {
const nextState = await startDevServer(
{
command: payload.command.trim(),
scriptName: payload.scriptName.trim(),
cwd: payload.cwd,
},
projectId,
);
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyServerState(nextState);
} catch (startError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
const message = normalizeError(startError);
setError(message);
throw startError;
}
}, [applyServerState, projectId]);
const stop = useCallback(async () => {
const versionAtStart = contextVersionRef.current;
setError(null);
try {
const nextState = await stopDevServer(projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyServerState(nextState);
} catch (stopError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(stopError));
throw stopError;
}
}, [applyServerState, projectId]);
const restart = useCallback(async () => {
const versionAtStart = contextVersionRef.current;
setError(null);
try {
const nextState = await restartDevServer(projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyServerState(nextState);
} catch (restartError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(restartError));
throw restartError;
}
}, [applyServerState, projectId]);
const setPreviewUrl = useCallback(async (url: string | null) => {
const versionAtStart = contextVersionRef.current;
setError(null);
try {
const nextState = await setDevServerPreviewUrl({ url }, projectId);
if (contextVersionRef.current !== versionAtStart) {
return;
}
applyServerState(nextState);
} catch (previewError) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(previewError));
throw previewError;
}
}, [applyServerState, projectId]);
return {
candidates,
serverState,
logs,
start,
stop,
restart,
setPreviewUrl,
loading,
error,
};
}