feat(FN-2179): complete Step 1-2 API and useDevServer hook
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user