feat(FN-2168): add persistent dev-server dashboard flow

- Add a durable dev-server store and manager with persisted state/log history, process reconciliation, and SSE event buffering
- Wire new backend lifecycle APIs for status/history/start/stop/restart/stream and initialize/shutdown manager handling in server startup
- Add dashboard Dev Server view, hook-based hydration/stream handling, nav integration, and supporting styles
- Expand coverage with backend persistence/route tests and frontend DevServerView/useDevServer tests
- Document the dev-server persistence architecture in docs/architecture.md
This commit is contained in:
Fusion
2026-04-19 14:55:14 -07:00
committed by gsxdsm
parent 66ad0769c4
commit 10f10c5a45
20 changed files with 2822 additions and 1266 deletions

View File

@@ -1,520 +1,549 @@
import { EventEmitter } from "node:events";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
import { resolve } from "node:path";
import {
loadDevServerStore,
resetDevServerStore,
type DevServerPersistedLogEntry,
type DevServerPersistedState,
type DevServerStatus,
type DevServerLogSource,
type DevServerStore,
} from "./dev-server-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
export interface DevServerState {
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;
}
const DEFAULT_SERVER_KEY = "default";
const DEFAULT_LOG_LIMIT = 200;
const DEFAULT_BUFFER_CAPACITY = 400;
const STOP_TIMEOUT_MS = 5_000;
interface PersistedDevServerState {
id: string;
name: string;
command: string;
scriptName: string;
cwd: string;
pid?: number;
startedAt?: string;
manualPreviewUrl?: string;
exitCode?: number | null;
}
export const MAX_LOG_LINES = 500;
// Reserved dashboard port 4040 must never be suggested as a fallback dev-server port.
export const FALLBACK_PORTS = [3000, 4173, 5173, 6006, 8080, 4200, 4400, 8888] as const;
function createDefaultState(projectRoot: string): DevServerState {
return {
id: "default",
name: "default",
status: "stopped",
command: "",
scriptName: "",
cwd: projectRoot,
logs: [],
exitCode: null,
};
export interface DevServerStartOptions {
command: string;
cwd?: string;
scriptName?: string | null;
}
function normalizeUrl(host: string, port: number): string {
const normalizedHost = host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" ? "localhost" : host;
return `http://${normalizedHost}:${port}`;
export interface DevServerSnapshot {
state: DevServerPersistedState;
logs: DevServerPersistedLogEntry[];
}
export function parseLineForUrl(line: string): { url: string; port: number } | null {
const patterns: RegExp[] = [
/(?:Local|local|ready on|listening on|started on|running at)\s*(?:http:\/\/)(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
/http:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
];
for (const pattern of patterns) {
const match = line.match(pattern);
if (!match) continue;
if (match.length === 3) {
const [, host, rawPort] = match;
const port = Number.parseInt(rawPort, 10);
if (!Number.isFinite(port)) return null;
return { url: normalizeUrl(host, port), port };
}
if (match.length === 2) {
const [, rawPort] = match;
const port = Number.parseInt(rawPort, 10);
if (!Number.isFinite(port)) return null;
return { url: normalizeUrl("localhost", port), port };
}
}
return null;
export interface DevServerManagerEvent {
type: "state" | "log";
data: DevServerPersistedState | DevServerPersistedLogEntry;
}
type DevServerSubscriber = (event: DevServerManagerEvent, eventId: number) => void;
export class DevServerManager extends EventEmitter {
private readonly stateFile: string;
private readonly servers = new Map<string, DevServerState>();
private readonly processes = new Map<string, ChildProcess>();
private readonly killTimers = new Map<string, NodeJS.Timeout>();
private portProbeTimer: NodeJS.Timeout | null = null;
private readonly loadPromise: Promise<void>;
private readonly subscribers = new Set<DevServerSubscriber>();
private readonly eventBuffer = new SessionEventBuffer(DEFAULT_BUFFER_CAPACITY);
private readonly logLimit: number;
constructor(private readonly projectRoot: string) {
private process: ChildProcessWithoutNullStreams | null = null;
private stopPromise: Promise<void> | null = null;
private state: DevServerPersistedState;
private logs: DevServerPersistedLogEntry[] = [];
private initialized = false;
private persistenceChain = Promise.resolve();
constructor(
private readonly rootDir: string,
private readonly store: DevServerStore,
options?: { logLimit?: number },
) {
super();
this.stateFile = path.join(this.projectRoot, ".fusion", "dev-server.json");
this.servers.set("default", createDefaultState(this.projectRoot));
this.loadPromise = this.loadState();
this.logLimit = options?.logLimit ?? DEFAULT_LOG_LIMIT;
this.state = createDefaultState();
}
async start(command: string, scriptName: string, cwd?: string): Promise<DevServerState> {
await this.loadPromise;
const current = this.getMutableState();
if (current.status !== "stopped") {
throw new Error(`Dev server is already ${current.status}`);
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
const resolvedCwd = cwd ? path.resolve(cwd) : this.projectRoot;
const startedAt = new Date().toISOString();
const nextState: DevServerState = {
...current,
id: "default",
name: "default",
status: "starting",
command,
scriptName,
cwd: resolvedCwd,
pid: undefined,
startedAt,
previewUrl: current.manualPreviewUrl,
detectedPort: undefined,
logs: [],
exitCode: null,
};
const loadedState = await this.store.loadState();
this.logs = await this.store.readLogTail(this.logLimit);
this.servers.set("default", nextState);
this.emit("status", this.cloneState(nextState));
const child = spawn(command, [], {
cwd: resolvedCwd,
shell: true,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
FORCE_COLOR: "1",
TERM: "xterm-256color",
},
});
if (child.pid) {
nextState.pid = child.pid;
if (loadedState) {
this.state = loadedState;
}
this.processes.set("default", child);
const onOutput = (chunk: Buffer, markRunning: boolean): void => {
const lines = chunk
.toString("utf-8")
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter((line) => line.length > 0);
for (const line of lines) {
const state = this.getMutableState();
this.pushLogLine(state, line);
if (markRunning && state.status === "starting") {
state.status = "running";
this.emit("status", this.cloneState(state));
if (!state.previewUrl) {
this.scheduleFallbackPortProbe();
}
}
this.tryParseAndApplyUrl(line);
this.emit("log", { serverId: "default", line });
}
};
child.stdout?.on("data", (data: Buffer) => {
onOutput(data, true);
});
child.stderr?.on("data", (data: Buffer) => {
onOutput(data, false);
});
child.on("exit", (code: number | null) => {
const state = this.getMutableState();
state.status = code === 0 ? "stopped" : "failed";
state.exitCode = code;
state.pid = undefined;
this.processes.delete("default");
this.clearKillTimer("default");
this.clearPortProbeTimer();
if (state.manualPreviewUrl) {
state.previewUrl = state.manualPreviewUrl;
}
this.emit("status", this.cloneState(state));
this.persistState();
});
child.on("error", (err: Error) => {
const state = this.getMutableState();
state.status = "failed";
state.exitCode = 1;
state.pid = undefined;
this.pushLogLine(state, `[dev-server] ${err.message}`);
this.processes.delete("default");
this.clearKillTimer("default");
this.clearPortProbeTimer();
this.emit("status", this.cloneState(state));
this.persistState();
});
this.persistState();
return this.getState();
await this.reconcilePersistedProcessState();
this.initialized = true;
}
async stop(): Promise<DevServerState> {
await this.loadPromise;
getState(): DevServerPersistedState {
return structuredClone(this.state);
}
const state = this.getMutableState();
const child = this.processes.get("default");
if (!child || (state.status !== "running" && state.status !== "starting")) {
getRecentLogs(limit = this.logLimit): DevServerPersistedLogEntry[] {
if (!Number.isFinite(limit) || limit <= 0) {
return [];
}
return this.logs.slice(-Math.floor(limit)).map((entry) => structuredClone(entry));
}
getSnapshot(limit = this.logLimit): DevServerSnapshot {
return {
state: this.getState(),
logs: this.getRecentLogs(limit),
};
}
getBufferedEvents(sinceId: number): SessionBufferedEvent[] {
return this.eventBuffer.getEventsSince(sinceId);
}
subscribe(callback: DevServerSubscriber): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async start(options: DevServerStartOptions): Promise<DevServerPersistedState> {
await this.ensureInitialized();
const command = options.command.trim();
if (!command) {
throw new Error("command is required");
}
if (this.state.pid && isProcessAlive(this.state.pid) && (this.state.status === "running" || this.state.status === "starting")) {
return this.getState();
}
state.status = "stopped";
this.emit("status", this.cloneState(state));
if (this.state.pid && !isProcessAlive(this.state.pid)) {
await this.setState({
status: "stopped",
pid: null,
exitCode: 1,
exitSignal: null,
exitedAt: new Date().toISOString(),
failureReason: "Persisted process is no longer running",
});
}
child.kill("SIGTERM");
const cwd = options.cwd ? resolve(options.cwd) : this.rootDir;
const now = new Date().toISOString();
const killTimer = setTimeout(() => {
if (!this.processes.has("default")) {
return;
}
await this.setState({
status: "starting",
command,
scriptName: options.scriptName ?? null,
cwd,
startedAt: now,
exitedAt: null,
exitCode: null,
exitSignal: null,
failureReason: null,
previewUrl: null,
previewProtocol: null,
previewHost: null,
previewPort: null,
previewPath: null,
pid: null,
});
const processToKill = this.processes.get("default");
processToKill?.kill("SIGKILL");
}, 5_000);
this.killTimers.set("default", killTimer);
this.appendLog({
serverKey: DEFAULT_SERVER_KEY,
source: "system",
message: `Starting dev server: ${command}`,
timestamp: now,
});
const child = spawn(command, {
cwd,
shell: true,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
this.process = child;
await this.setState({ pid: child.pid ?? null });
child.once("spawn", () => {
void this.setState({ status: "running" });
});
child.once("error", (error) => {
const failedAt = new Date().toISOString();
this.appendLog({
serverKey: DEFAULT_SERVER_KEY,
source: "system",
message: `Dev server failed to start: ${error.message}`,
timestamp: failedAt,
});
void this.setState({
status: "failed",
pid: null,
exitCode: 1,
exitSignal: null,
exitedAt: failedAt,
failureReason: error.message,
});
this.process = null;
});
child.once("exit", (code, signal) => {
const exitedAt = new Date().toISOString();
const failed = (code ?? 0) !== 0;
this.appendLog({
serverKey: DEFAULT_SERVER_KEY,
source: "system",
message: failed
? `Dev server exited with code ${code ?? "unknown"}`
: "Dev server stopped",
timestamp: exitedAt,
});
void this.setState({
status: failed ? "failed" : "stopped",
pid: null,
exitCode: code ?? null,
exitSignal: signal ?? null,
exitedAt,
failureReason: failed ? `Process exited with code ${code ?? "unknown"}` : null,
});
this.process = null;
});
this.pipeOutput(child, "stdout");
this.pipeOutput(child, "stderr");
this.clearPortProbeTimer();
this.persistState();
return this.getState();
}
async restart(): Promise<DevServerState> {
await this.loadPromise;
async stop(reason = "Stopped by user"): Promise<DevServerPersistedState> {
await this.ensureInitialized();
const state = this.getMutableState();
const command = state.command;
const scriptName = state.scriptName;
const cwd = state.cwd;
if (!command || !scriptName) {
throw new Error("Cannot restart dev server before it has been started once");
if (this.stopPromise) {
await this.stopPromise;
return this.getState();
}
if (state.status !== "stopped" && state.status !== "failed") {
await this.stop();
await this.waitForProcessExit("default", 5_500);
}
this.stopPromise = this.stopInternal(reason)
.finally(() => {
this.stopPromise = null;
});
return this.start(command, scriptName, cwd);
}
getState(): DevServerState {
const state = this.servers.get("default");
if (!state) {
return createDefaultState(this.projectRoot);
}
return this.cloneState(state);
}
getAllStates(): DevServerState[] {
return Array.from(this.servers.values()).map((state) => this.cloneState(state));
}
getLogs(tail?: number): string[] {
const logs = this.getMutableState().logs;
if (tail === undefined || tail <= 0 || tail >= logs.length) {
return [...logs];
}
return logs.slice(-tail);
}
setManualPreviewUrl(url: string | null): DevServerState {
const state = this.getMutableState();
state.manualPreviewUrl = url ?? undefined;
if (url) {
state.previewUrl = url;
} else if (state.detectedPort) {
state.previewUrl = normalizeUrl("localhost", state.detectedPort);
} else {
state.previewUrl = undefined;
}
this.emit("status", this.cloneState(state));
this.persistState();
await this.stopPromise;
return this.getState();
}
destroy(): void {
for (const [serverId, child] of this.processes.entries()) {
child.kill("SIGTERM");
const killTimer = setTimeout(() => {
if (!this.processes.has(serverId)) {
return;
}
this.processes.get(serverId)?.kill("SIGKILL");
}, 5_000);
this.killTimers.set(serverId, killTimer);
}
for (const timer of this.killTimers.values()) {
clearTimeout(timer);
}
this.killTimers.clear();
this.clearPortProbeTimer();
this.removeAllListeners();
this.processes.clear();
this.servers.clear();
async restart(options: DevServerStartOptions): Promise<DevServerPersistedState> {
await this.stop("Restarting dev server");
return this.start(options);
}
private getMutableState(): DevServerState {
const state = this.servers.get("default");
if (state) {
return state;
async shutdown(): Promise<void> {
if (this.process || (this.state.pid && isProcessAlive(this.state.pid))) {
await this.stop("Dashboard backend shutting down");
}
const fallback = createDefaultState(this.projectRoot);
this.servers.set("default", fallback);
return fallback;
await this.flushPersistence();
}
private cloneState(state: DevServerState): DevServerState {
return {
...state,
logs: [...state.logs],
};
resetForTests(): void {
this.subscribers.clear();
this.eventBuffer.clear();
this.logs = [];
this.process = null;
this.stopPromise = null;
this.state = createDefaultState();
this.initialized = false;
this.persistenceChain = Promise.resolve();
}
private tryParseAndApplyUrl(line: string): void {
const parsed = parseLineForUrl(line);
if (!parsed) {
private async stopInternal(reason: string): Promise<void> {
const now = new Date().toISOString();
this.appendLog({
serverKey: DEFAULT_SERVER_KEY,
source: "system",
message: reason,
timestamp: now,
});
const currentPid = this.process?.pid ?? this.state.pid;
if (!currentPid) {
await this.setState({
status: "stopped",
pid: null,
exitCode: 0,
exitSignal: null,
exitedAt: now,
failureReason: null,
});
return;
}
const state = this.getMutableState();
state.detectedPort = parsed.port;
if (!state.manualPreviewUrl) {
state.previewUrl = parsed.url;
if (!isProcessAlive(currentPid)) {
await this.setState({
status: "stopped",
pid: null,
exitCode: 0,
exitSignal: null,
exitedAt: now,
failureReason: null,
});
return;
}
this.emit("url-detected", { serverId: "default", url: parsed.url, port: parsed.port });
this.clearPortProbeTimer();
this.emit("status", this.cloneState(state));
this.persistState();
}
try {
process.kill(currentPid, "SIGTERM");
} catch {
// process may have already exited
}
private scheduleFallbackPortProbe(): void {
this.clearPortProbeTimer();
this.portProbeTimer = setTimeout(() => {
this.probeFallbackPorts();
}, 10_000);
}
const gracefulStop = waitForProcessExit(currentPid, STOP_TIMEOUT_MS);
const exited = await gracefulStop;
private probeFallbackPorts(): void {
void (async () => {
for (const port of FALLBACK_PORTS) {
const available = await this.testPort(port);
if (!available) {
continue;
}
const state = this.getMutableState();
state.detectedPort = port;
if (!state.manualPreviewUrl) {
state.previewUrl = normalizeUrl("localhost", port);
}
this.emit("url-detected", { serverId: "default", url: normalizeUrl("localhost", port), port });
this.emit("status", this.cloneState(state));
this.persistState();
return;
if (!exited) {
try {
process.kill(currentPid, "SIGKILL");
} catch {
// already gone
}
})();
}
}
private async testPort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ port, host: "localhost" });
const complete = (result: boolean): void => {
socket.removeAllListeners();
if (!socket.destroyed) {
socket.destroy();
}
resolve(result);
};
socket.setTimeout(500);
socket.once("connect", () => complete(true));
socket.once("timeout", () => complete(false));
socket.once("error", () => complete(false));
await this.setState({
status: "stopped",
pid: null,
exitCode: 0,
exitSignal: exited ? "SIGTERM" : "SIGKILL",
exitedAt: new Date().toISOString(),
failureReason: null,
});
}
private clearPortProbeTimer(): void {
if (!this.portProbeTimer) {
return;
}
private pipeOutput(processRef: ChildProcessWithoutNullStreams, source: DevServerLogSource): void {
const stream = source === "stdout" ? processRef.stdout : processRef.stderr;
const rl = createInterface({ input: stream });
clearTimeout(this.portProbeTimer);
this.portProbeTimer = null;
}
private clearKillTimer(serverId: string): void {
const timer = this.killTimers.get(serverId);
if (!timer) {
return;
}
clearTimeout(timer);
this.killTimers.delete(serverId);
}
private pushLogLine(state: DevServerState, line: string): void {
state.logs.push(line);
if (state.logs.length > MAX_LOG_LINES) {
state.logs.splice(0, state.logs.length - MAX_LOG_LINES);
}
}
private async waitForProcessExit(serverId: string, timeoutMs: number): Promise<void> {
const start = Date.now();
while (this.processes.has(serverId) && Date.now() - start < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
private persistState(): void {
const state = this.getMutableState();
const payload: PersistedDevServerState = {
id: state.id,
name: state.name,
command: state.command,
scriptName: state.scriptName,
cwd: state.cwd,
manualPreviewUrl: state.manualPreviewUrl,
exitCode: state.exitCode,
pid: state.pid,
startedAt: state.startedAt,
};
void (async () => {
try {
await mkdir(path.dirname(this.stateFile), { recursive: true });
await writeFile(this.stateFile, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
} catch (err) {
console.error("[dev-server] Failed to persist state:", err);
}
})();
}
private async loadState(): Promise<void> {
try {
const raw = await readFile(this.stateFile, "utf-8");
const parsed = JSON.parse(raw) as Partial<PersistedDevServerState>;
const state = this.getMutableState();
state.command = typeof parsed.command === "string" ? parsed.command : "";
state.scriptName = typeof parsed.scriptName === "string" ? parsed.scriptName : "";
state.cwd = typeof parsed.cwd === "string" ? parsed.cwd : this.projectRoot;
state.manualPreviewUrl = typeof parsed.manualPreviewUrl === "string" ? parsed.manualPreviewUrl : undefined;
state.previewUrl = state.manualPreviewUrl;
state.startedAt = typeof parsed.startedAt === "string" ? parsed.startedAt : undefined;
state.exitCode = parsed.exitCode ?? null;
const maybePid = typeof parsed.pid === "number" ? parsed.pid : undefined;
if (maybePid === undefined) {
state.status = "stopped";
state.pid = undefined;
rl.on("line", (line) => {
const message = line.trim();
if (!message) {
return;
}
try {
process.kill(maybePid, 0);
state.status = "running";
state.pid = maybePid;
} catch {
state.status = "stopped";
state.pid = undefined;
const timestamp = new Date().toISOString();
this.appendLog({
serverKey: DEFAULT_SERVER_KEY,
source,
message,
timestamp,
});
const detectedPreview = detectPreviewUrl(message);
if (detectedPreview) {
const parsed = parsePreviewUrl(detectedPreview);
if (parsed) {
void this.setState({
status: "running",
previewUrl: parsed.previewUrl,
previewProtocol: parsed.previewProtocol,
previewHost: parsed.previewHost,
previewPort: parsed.previewPort,
previewPath: parsed.previewPath,
});
}
}
} catch {
this.servers.set("default", createDefaultState(this.projectRoot));
});
}
private appendLog(entry: DevServerPersistedLogEntry): void {
this.logs.push(entry);
if (this.logs.length > this.logLimit) {
this.logs.splice(0, this.logs.length - this.logLimit);
}
void this.enqueuePersistence(async () => {
await this.store.appendLog(entry);
});
this.broadcast({ type: "log", data: entry });
}
private async setState(patch: Partial<DevServerPersistedState>): Promise<void> {
this.state = {
...this.state,
...patch,
serverKey: DEFAULT_SERVER_KEY,
updatedAt: new Date().toISOString(),
};
await this.enqueuePersistence(async () => {
await this.store.saveState(this.state);
});
this.broadcast({ type: "state", data: this.state });
}
private broadcast(event: DevServerManagerEvent): number {
const payload = JSON.stringify(event.data);
const eventId = this.eventBuffer.push(event.type, payload);
for (const subscriber of this.subscribers) {
try {
subscriber(event, eventId);
} catch {
// Ignore per-subscriber failures.
}
}
this.emit("event", event, eventId);
return eventId;
}
private async reconcilePersistedProcessState(): Promise<void> {
const shouldVerify = this.state.status === "running" || this.state.status === "starting";
if (!shouldVerify || !this.state.pid) {
return;
}
if (isProcessAlive(this.state.pid)) {
return;
}
await this.setState({
status: "stopped",
pid: null,
exitCode: 1,
exitSignal: null,
exitedAt: new Date().toISOString(),
failureReason: "Recovered process is no longer running",
});
this.appendLog({
serverKey: DEFAULT_SERVER_KEY,
source: "system",
message: "Recovered stale persisted PID and marked server as stopped",
timestamp: new Date().toISOString(),
});
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
private async enqueuePersistence(operation: () => Promise<void>): Promise<void> {
this.persistenceChain = this.persistenceChain
.then(operation)
.catch(() => {
// Keep the chain alive even if one write fails.
});
await this.persistenceChain;
}
private async flushPersistence(): Promise<void> {
await this.persistenceChain;
}
}
const managers: Map<string, DevServerManager> = new Map();
function createDefaultState(): DevServerPersistedState {
return {
serverKey: DEFAULT_SERVER_KEY,
status: "idle",
command: null,
scriptName: null,
cwd: null,
pid: null,
startedAt: null,
updatedAt: new Date().toISOString(),
previewUrl: null,
previewProtocol: null,
previewHost: null,
previewPort: null,
previewPath: null,
exitCode: null,
exitSignal: null,
exitedAt: null,
failureReason: null,
};
}
export function getDevServerManager(projectRoot: string): DevServerManager {
const resolvedRoot = path.resolve(projectRoot);
const existing = managers.get(resolvedRoot);
if (existing) {
return existing;
function detectPreviewUrl(message: string): string | null {
const match = message.match(/https?:\/\/[^\s]+/i);
return match?.[0] ?? null;
}
function parsePreviewUrl(urlValue: string): {
previewUrl: string;
previewProtocol: string;
previewHost: string;
previewPort: number | null;
previewPath: string;
} | null {
try {
const parsed = new URL(urlValue);
return {
previewUrl: parsed.toString(),
previewProtocol: parsed.protocol.replace(/:$/, ""),
previewHost: parsed.hostname,
previewPort: parsed.port ? Number.parseInt(parsed.port, 10) : null,
previewPath: parsed.pathname || "/",
};
} catch {
return null;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
const intervalMs = 100;
const maxChecks = Math.ceil(timeoutMs / intervalMs);
for (let i = 0; i < maxChecks; i += 1) {
if (!isProcessAlive(pid)) {
return true;
}
await new Promise((resolvePromise) => {
setTimeout(resolvePromise, intervalMs);
});
}
return !isProcessAlive(pid);
}
const managerInstances = new Map<string, DevServerManager>();
export async function loadDevServerManager(rootDir: string): Promise<DevServerManager> {
const key = resolve(rootDir);
let manager = managerInstances.get(key);
if (!manager) {
const store = await loadDevServerStore(rootDir);
manager = new DevServerManager(key, store);
managerInstances.set(key, manager);
await manager.initialize();
}
const manager = new DevServerManager(resolvedRoot);
managers.set(resolvedRoot, manager);
return manager;
}
export function destroyAllDevServerManagers(): void {
for (const manager of managers.values()) {
manager.destroy();
export async function shutdownAllDevServerManagers(): Promise<void> {
for (const manager of managerInstances.values()) {
await manager.shutdown();
}
managers.clear();
}
export function resetDevServerManager(): void {
managerInstances.clear();
resetDevServerStore();
}