feat(FN-2179): complete Step 1-2 API and useDevServer hook

This commit is contained in:
Fusion
2026-04-20 04:35:08 -07:00
committed by gsxdsm
parent 7b8bbaaa0a
commit d0bd6c2329
2 changed files with 412 additions and 257 deletions

View File

@@ -1948,121 +1948,147 @@ function createResilientEventSource(
};
}
export type DevServerStatus = "idle" | "starting" | "running" | "stopped" | "failed";
export interface DevServerCandidate {
name: string;
command: string;
scriptName: string;
cwd: string;
label: string;
}
export interface DevServerState {
serverKey: string;
status: DevServerStatus;
command: string | null;
scriptName: string | null;
cwd: string | null;
pid: number | null;
startedAt: string | null;
updatedAt: string;
previewUrl: string | null;
previewProtocol: string | null;
previewHost: string | null;
previewPort: number | null;
previewPath: string | null;
exitCode: number | null;
exitSignal: string | null;
exitedAt: string | null;
failureReason: string | null;
}
export interface DevServerLogEntry {
serverKey: string;
source: "stdout" | "stderr" | "system";
message: string;
timestamp: string;
}
export interface DevServerSnapshot {
state: DevServerState;
logs: DevServerLogEntry[];
id: string;
name: string;
status: "stopped" | "starting" | "running" | "failed";
command: string;
scriptName: string;
cwd: string;
pid?: number;
startedAt?: string;
previewUrl?: string;
detectedPort?: number;
manualPreviewUrl?: string;
logs: string[];
exitCode?: number | null;
}
export interface DevServerStartInput {
command: string;
scriptName: string;
cwd?: string;
scriptName?: string;
}
export function fetchDevServerStatus(projectId?: string): Promise<DevServerSnapshot> {
return api<DevServerSnapshot>(withProjectId("/dev-server/status", projectId));
interface BackendDevServerCandidate {
name: string;
command: string;
source?: string;
packageName?: string;
}
export function fetchDevServerHistory(projectId?: string, limit = 200): Promise<{ logs: DevServerLogEntry[] }> {
const params = new URLSearchParams();
if (Number.isFinite(limit) && limit > 0) {
params.set("limit", String(Math.floor(limit)));
}
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return api<{ logs: DevServerLogEntry[] }>(withProjectId(`/dev-server/history${suffix}`, projectId));
interface BackendDevServerState {
id?: string;
name?: string;
status?: "stopped" | "starting" | "running" | "failed";
command?: string;
scriptId?: string;
cwd?: string;
pid?: number;
startedAt?: string;
detectedUrl?: string;
detectedPort?: number;
manualUrl?: string;
logHistory?: string[];
exitCode?: number | null;
}
export function startDevServer(input: DevServerStartInput, projectId?: string): Promise<{ state: DevServerState }> {
return api<{ state: DevServerState }>(withProjectId("/dev-server/start", projectId), {
method: "POST",
body: JSON.stringify(input),
});
function mapBackendCandidateToFrontend(candidate: BackendDevServerCandidate): DevServerCandidate {
const source = typeof candidate.source === "string" && candidate.source.trim().length > 0
? candidate.source.trim()
: "root";
const cwd = source === "root" ? "." : source;
const scriptName = candidate.name;
const locationLabel = source === "root" ? "root" : source;
const packageLabel = typeof candidate.packageName === "string" && candidate.packageName.trim().length > 0
? candidate.packageName.trim()
: "project";
return {
name: candidate.name,
command: candidate.command,
scriptName,
cwd,
label: `${packageLabel} · ${scriptName} (${locationLabel})`,
};
}
export function stopDevServer(projectId?: string): Promise<{ state: DevServerState }> {
return api<{ state: DevServerState }>(withProjectId("/dev-server/stop", projectId), {
method: "POST",
});
function mapBackendStateToFrontend(state: BackendDevServerState): DevServerState {
const status = state.status;
const normalizedStatus = status === "starting" || status === "running" || status === "failed" || status === "stopped"
? status
: "stopped";
return {
id: typeof state.id === "string" ? state.id : "",
name: typeof state.name === "string" && state.name.length > 0 ? state.name : "default",
status: normalizedStatus,
command: typeof state.command === "string" ? state.command : "",
scriptName: typeof state.scriptId === "string" ? state.scriptId : "",
cwd: typeof state.cwd === "string" ? state.cwd : "",
pid: state.pid,
startedAt: state.startedAt,
previewUrl: state.detectedUrl,
detectedPort: state.detectedPort,
manualPreviewUrl: state.manualUrl,
logs: Array.isArray(state.logHistory) ? state.logHistory : [],
exitCode: state.exitCode,
};
}
export function restartDevServer(input?: Partial<DevServerStartInput>, projectId?: string): Promise<{ state: DevServerState }> {
return api<{ state: DevServerState }>(withProjectId("/dev-server/restart", projectId), {
method: "POST",
body: JSON.stringify(input ?? {}),
});
}
export function getDevServerStreamUrl(projectId?: string): string {
return buildApiUrl(withProjectId("/dev-server/stream", projectId));
}
export function connectDevServerStream(
projectId: string | undefined,
handlers: {
onState?: (state: DevServerState) => void;
onLog?: (entry: DevServerLogEntry) => void;
onConnectionStateChange?: (state: StreamConnectionState) => void;
onError?: (message: string) => void;
},
options?: { maxReconnectAttempts?: number },
): { close: () => void; isConnected: () => boolean } {
return createResilientEventSource(
getDevServerStreamUrl(projectId),
{
events: {
state: (event) => {
try {
handlers.onState?.(JSON.parse(event.data) as DevServerState);
} catch {
// Ignore malformed events.
}
},
log: (event) => {
try {
handlers.onLog?.(JSON.parse(event.data) as DevServerLogEntry);
} catch {
// Ignore malformed events.
}
},
},
},
{
maxReconnectAttempts: options?.maxReconnectAttempts,
onConnectionStateChange: handlers.onConnectionStateChange,
onFatalError: (message) => handlers.onError?.(message),
},
export function fetchDevServerCandidates(projectId?: string): Promise<DevServerCandidate[]> {
return api<{ candidates: BackendDevServerCandidate[] }>(withProjectId("/dev-server/detect", projectId)).then((response) =>
(response.candidates ?? []).map(mapBackendCandidateToFrontend)
);
}
export function fetchDevServerStatus(projectId?: string): Promise<DevServerState> {
return api<BackendDevServerState>(withProjectId("/dev-server/status", projectId)).then(mapBackendStateToFrontend);
}
export function startDevServer(body: DevServerStartInput, projectId?: string): Promise<DevServerState> {
return api<BackendDevServerState>(withProjectId("/dev-server/start", projectId), {
method: "POST",
body: JSON.stringify({
command: body.command,
scriptId: body.scriptName,
cwd: body.cwd ?? ".",
}),
}).then(mapBackendStateToFrontend);
}
export function stopDevServer(projectId?: string): Promise<DevServerState> {
return api<BackendDevServerState>(withProjectId("/dev-server/stop", projectId), {
method: "POST",
}).then(mapBackendStateToFrontend);
}
export function restartDevServer(projectId?: string): Promise<DevServerState> {
return api<BackendDevServerState>(withProjectId("/dev-server/restart", projectId), {
method: "POST",
}).then(mapBackendStateToFrontend);
}
export function setDevServerPreviewUrl(body: { url: string | null }, projectId?: string): Promise<DevServerState> {
return api<BackendDevServerState>(withProjectId("/dev-server/preview-url", projectId), {
method: "PUT",
body: JSON.stringify(body),
}).then(mapBackendStateToFrontend);
}
export function getDevServerLogsStreamUrl(projectId?: string): string {
return buildApiUrl(withProjectId("/dev-server/logs/stream", projectId));
}
function startKeepAlive(
sessionId: string,
projectId?: string,

View File

@@ -1,220 +1,349 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
connectDevServerStream,
fetchDevServerHistory,
fetchDevServerCandidates,
fetchDevServerStatus,
getDevServerLogsStreamUrl,
restartDevServer,
setDevServerPreviewUrl,
startDevServer,
stopDevServer,
type DevServerLogEntry,
type DevServerSnapshot,
type DevServerCandidate,
type DevServerState,
} from "../api";
const MAX_LOG_ENTRIES = 500;
const DEFAULT_DEV_SERVER_STATE: DevServerState = {
serverKey: "default",
status: "idle",
command: null,
scriptName: null,
cwd: null,
pid: null,
startedAt: null,
updatedAt: new Date(0).toISOString(),
previewUrl: null,
previewProtocol: null,
previewHost: null,
previewPort: null,
previewPath: null,
exitCode: null,
exitSignal: null,
exitedAt: null,
failureReason: null,
};
const POLL_INTERVAL_MS = 3000;
const MAX_LOG_LINES = 500;
function normalizeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function logSignature(entry: DevServerLogEntry): string {
return `${entry.serverKey}|${entry.source}|${entry.timestamp}|${entry.message}`;
}
function dedupeLogs(entries: DevServerLogEntry[]): DevServerLogEntry[] {
const seen = new Set<string>();
const output: DevServerLogEntry[] = [];
for (const entry of entries) {
const key = logSignature(entry);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(entry);
function normalizeLines(lines: string[]): string[] {
if (lines.length <= MAX_LOG_LINES) {
return lines;
}
return output.length > MAX_LOG_ENTRIES
? output.slice(-MAX_LOG_ENTRIES)
: output;
return lines.slice(-MAX_LOG_LINES);
}
export interface UseDevServerResult {
state: DevServerState;
logs: DevServerLogEntry[];
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;
connectionState: "connected" | "reconnecting" | "disconnected";
start: (options: { command: string; cwd?: string; scriptName?: string }) => Promise<void>;
stop: () => Promise<void>;
restart: (options?: { command?: string; cwd?: string; scriptName?: string }) => Promise<void>;
refresh: () => Promise<void>;
manualPreviewUrl: string;
setManualPreviewUrl: (url: string) => void;
effectivePreviewUrl: string | null;
}
export function useDevServer(projectId?: string): UseDevServerResult {
const [state, setState] = useState<DevServerState>(DEFAULT_DEV_SERVER_STATE);
const [logs, setLogs] = useState<DevServerLogEntry[]>([]);
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 [connectionState, setConnectionState] = useState<"connected" | "reconnecting" | "disconnected">("disconnected");
const [manualPreviewUrl, setManualPreviewUrl] = useState("");
const pollingIntervalRef = useRef<number | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const contextVersionRef = useRef(0);
const streamCloseRef = useRef<(() => void) | null>(null);
const applySnapshot = useCallback((snapshot: DevServerSnapshot, historyLogs: DevServerLogEntry[]) => {
setState(snapshot.state);
setLogs(dedupeLogs([...historyLogs, ...snapshot.logs]));
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current !== null) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}, []);
const refresh = useCallback(async () => {
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;
const [snapshot, history] = await Promise.all([
fetchDevServerStatus(projectId),
fetchDevServerHistory(projectId, 300),
]);
if (contextVersionRef.current !== versionAtStart) {
return;
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();
}
applySnapshot(snapshot, history.logs);
}, [applySnapshot, projectId]);
}, [applyServerState, projectId, stopPolling]);
useEffect(() => {
contextVersionRef.current += 1;
const versionAtStart = contextVersionRef.current;
const versionAtStart = contextVersionRef.current + 1;
contextVersionRef.current = versionAtStart;
streamCloseRef.current?.();
streamCloseRef.current = null;
setState(DEFAULT_DEV_SERVER_STATE);
setCandidates([]);
setServerState(null);
setLogs([]);
setLoading(true);
setError(null);
setConnectionState("disconnected");
setManualPreviewUrl("");
const initialize = async () => {
try {
const [snapshot, history] = await Promise.all([
fetchDevServerStatus(projectId),
fetchDevServerHistory(projectId, 300),
]);
stopPolling();
closeEventSource();
if (contextVersionRef.current !== versionAtStart) {
return;
}
applySnapshot(snapshot, history.logs);
} catch (err) {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setError(normalizeError(err));
} finally {
if (contextVersionRef.current === versionAtStart) {
setLoading(false);
}
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;
}
const connection = connectDevServerStream(projectId, {
onState: (nextState) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setState(nextState);
},
onLog: (entry) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setLogs((prev) => dedupeLogs([...prev, entry]));
},
onConnectionStateChange: (nextConnectionState) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setConnectionState(nextConnectionState);
},
onError: (message) => {
if (contextVersionRef.current !== versionAtStart) {
return;
}
setConnectionState("disconnected");
setError(message);
},
});
streamCloseRef.current = connection.close;
};
void initialize();
return () => {
streamCloseRef.current?.();
streamCloseRef.current = null;
};
}, [applySnapshot, projectId]);
const start = useCallback(async (options: { command: string; cwd?: string; scriptName?: string }) => {
setError(null);
const response = await startDevServer(options, projectId);
setState(response.state);
}, [projectId]);
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);
const response = await stopDevServer(projectId);
setState(response.state);
}, [projectId]);
const restart = useCallback(async (options?: { command?: string; cwd?: string; scriptName?: string }) => {
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);
const response = await restartDevServer(options, projectId);
setState(response.state);
}, [projectId]);
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 {
state,
candidates,
serverState,
logs,
loading,
error,
connectionState,
start,
stop,
restart,
refresh,
manualPreviewUrl,
setManualPreviewUrl,
effectivePreviewUrl: manualPreviewUrl.trim() || state.previewUrl,
setPreviewUrl,
loading,
error,
};
}