Files
fusion/packages/dashboard/src/devserver-manager.ts
gsxdsm 4101af8547 refactor: eliminate remaining 15 any warnings and ratchet rule to error
- TaskCard: four catch((err: any) => err.message) promise handlers in
  archive/unarchive/delete/move → catch((err) => getErrorMessage(err)).
- InlineCreateCard + QuickEntryBox: .catch((err: any)) model-load handlers
  → getErrorMessage(err) with existing @fusion/core import.
- TerminalModal: drop (navigator as any).maxTouchPoints — modern lib.dom
  types already expose the property.
- serve.ts: remove unused any annotation on OpenRouter model mapper; the
  array element type is already inferred from json.data.
- pi.js, runtime-resolution.ts, dashboard.ts, serve.ts, dev-server-port-
  detect.ts, devserver-manager.ts: drop now-stale eslint-disable comments
  that the cleanup made redundant.

Fix a prompt-builder regression surfaced by agent's `any` cleanup: toolCall
with a raw string `arguments` field must be preserved verbatim (JSON-quoted)
rather than coerced to `{}`; restores a previously-passing test.

Then promote @typescript-eslint/no-explicit-any from warn → error. Future
new anys must either come with a one-line disable + justification or use a
real type. Workspace is now lint-clean (0 problems).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:02:37 -07:00

469 lines
12 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { createConnection } from "node:net";
import path from "node:path";
import {
MAX_LOG_ENTRIES,
type DevServerConfig,
type DevServerId,
type DevServerLogEntry,
type DevServerSession,
type DevServerSessionMap,
type DevServerStatus,
} from "./devserver-types.js";
const PORT_PROBE_DELAY_MS = 10_000;
const PORT_PROBE_TIMEOUT_MS = 500;
const PORT_PROBE_DEADLINE_MS = 3_000;
const STOP_TIMEOUT_MS = 5_000;
const COMMON_DEV_PORTS = [3000, 4173, 5173, 6006, 8080, 8888];
export interface DevServerManagerEvents {
log: [id: DevServerId, entry: DevServerLogEntry];
status: [id: DevServerId, status: DevServerStatus];
preview: [id: DevServerId, url: string | null];
exit: [id: DevServerId, exitCode: number];
}
export class DevServerManager extends EventEmitter<DevServerManagerEvents> {
private readonly sessions: DevServerSessionMap = new Map();
private readonly processes = new Map<string, ChildProcess>();
private readonly portProbes = new Map<string, NodeJS.Timeout>();
private readonly stopping = new Set<string>();
private readonly isWindows: boolean;
constructor(_projectRoot: string) {
super();
this.isWindows = process.platform === "win32";
}
async startServer(config: DevServerConfig): Promise<DevServerSession> {
const existing = this.sessions.get(config.id);
if (existing && (existing.status === "running" || existing.status === "starting")) {
throw new Error(`Dev server ${config.id} is already ${existing.status}`);
}
const session: DevServerSession = {
config,
status: "starting",
logHistory: [],
};
this.sessions.set(config.id, session);
this.emit("status", config.id, "starting");
const { command, args } = parseCommand(config.command);
if (!command) {
session.status = "failed";
this.emit("status", config.id, "failed");
throw new Error(`Invalid command for dev server ${config.id}`);
}
const child = spawn(command, args, {
cwd: config.cwd,
env: {
...process.env,
...(config.env ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
});
this.processes.set(config.id, child);
session.runtime = {
pid: child.pid ?? 0,
startedAt: new Date().toISOString(),
};
const handleChunk = (stream: "stdout" | "stderr", chunk: Buffer | string) => {
const activeSession = this.sessions.get(config.id);
if (!activeSession) {
return;
}
const text = chunk.toString();
if (!text) {
return;
}
if (activeSession.status === "starting") {
activeSession.status = "running";
this.emit("status", config.id, "running");
}
const entry: DevServerLogEntry = {
timestamp: new Date().toISOString(),
stream,
text,
};
activeSession.logHistory.push(entry);
if (activeSession.logHistory.length > MAX_LOG_ENTRIES) {
activeSession.logHistory.splice(0, activeSession.logHistory.length - MAX_LOG_ENTRIES);
}
this.emit("log", config.id, entry);
this.detectPreviewUrl(config.id, text);
};
child.stdout?.on("data", (chunk: Buffer | string) => {
handleChunk("stdout", chunk);
});
child.stderr?.on("data", (chunk: Buffer | string) => {
handleChunk("stderr", chunk);
});
child.on("error", (error) => {
const activeSession = this.sessions.get(config.id);
if (!activeSession) {
return;
}
console.error("[devserver] process error", {
id: config.id,
error: error.message,
});
activeSession.status = "failed";
this.emit("status", config.id, "failed");
});
child.on("close", (exitCode) => {
const activeSession = this.sessions.get(config.id);
if (!activeSession) {
return;
}
const normalizedExitCode = typeof exitCode === "number" ? exitCode : 0;
const wasStopping = this.stopping.delete(config.id);
activeSession.status = wasStopping || normalizedExitCode === 0 ? "stopped" : "failed";
if (activeSession.runtime) {
activeSession.runtime.exitCode = normalizedExitCode;
}
this.emit("status", config.id, activeSession.status);
this.emit("exit", config.id, normalizedExitCode);
this.processes.delete(config.id);
this.clearPortProbe(config.id);
console.info("[devserver] process closed", {
id: config.id,
exitCode: normalizedExitCode,
status: activeSession.status,
});
});
this.startPortProbe(config.id);
return session;
}
async stopServer(id: DevServerId): Promise<void> {
const session = this.sessions.get(id);
if (!session) {
throw new Error(`Dev server ${id} is not registered`);
}
const child = this.processes.get(id);
if (!child) {
throw new Error(`Dev server ${id} is not running`);
}
this.stopping.add(id);
session.status = "stopping";
this.emit("status", id, "stopping");
const closePromise = waitForClose(child);
this.sendTerminate(child);
const closeResult = await Promise.race([
closePromise,
delay<{ timedOut: true }>(STOP_TIMEOUT_MS, { timedOut: true }),
]);
if (closeResult.timedOut) {
this.sendKill(child);
await Promise.race([closePromise, delay(1_000, null)]);
}
const activeSession = this.sessions.get(id);
if (activeSession && activeSession.status !== "failed") {
activeSession.status = "stopped";
this.emit("status", id, "stopped");
}
this.processes.delete(id);
this.clearPortProbe(id);
}
async restartServer(id: DevServerId): Promise<DevServerSession> {
const session = this.sessions.get(id);
if (!session) {
throw new Error(`Dev server ${id} is not registered`);
}
if (this.processes.has(id)) {
await this.stopServer(id);
}
return this.startServer(session.config);
}
getSession(id: DevServerId): DevServerSession | undefined {
return this.sessions.get(id);
}
listSessions(): DevServerSession[] {
return Array.from(this.sessions.values());
}
getLogs(id: DevServerId, opts?: { tail?: number }): DevServerLogEntry[] {
const session = this.sessions.get(id);
if (!session) {
return [];
}
if (opts?.tail === undefined) {
return [...session.logHistory];
}
return session.logHistory.slice(-Math.max(0, opts.tail));
}
setPreviewUrl(id: DevServerId, url: string | null): void {
const session = this.sessions.get(id);
if (!session) {
return;
}
if (url === null) {
delete session.previewUrl;
if (session.runtime) {
delete session.runtime.previewUrl;
}
this.emit("preview", id, null);
return;
}
session.previewUrl = url;
if (session.runtime) {
session.runtime.previewUrl = url;
}
this.emit("preview", id, url);
}
destroy(): void {
for (const timeout of this.portProbes.values()) {
clearTimeout(timeout);
}
this.portProbes.clear();
for (const [id, child] of this.processes.entries()) {
this.stopping.add(id);
this.sendTerminate(child);
}
this.processes.clear();
this.sessions.clear();
this.stopping.clear();
this.removeAllListeners();
}
private detectPreviewUrl(id: DevServerId, text: string): void {
const match = text.match(/https?:\/\/(localhost|127\.0\.0\.1)(?::(\d+))?/);
if (!match) {
return;
}
const host = match[1];
const port = match[2] ? `:${match[2]}` : "";
const url = `http://${host}${port}`;
this.setPreviewUrl(id, url);
}
private startPortProbe(id: DevServerId): void {
this.clearPortProbe(id);
const timer = setTimeout(() => {
void this.runPortProbe(id);
}, PORT_PROBE_DELAY_MS);
this.portProbes.set(id, timer);
}
private async runPortProbe(id: DevServerId): Promise<void> {
try {
const session = this.sessions.get(id);
if (!session || session.status !== "running" || session.previewUrl) {
return;
}
const probePromise = this.findFirstReachablePort(COMMON_DEV_PORTS);
const result = await Promise.race([
probePromise,
delay<number | null>(PORT_PROBE_DEADLINE_MS, null),
]);
if (result === null) {
return;
}
this.setPreviewUrl(id, `http://localhost:${result}`);
} finally {
this.clearPortProbe(id);
}
}
private async findFirstReachablePort(ports: number[]): Promise<number | null> {
for (const port of ports) {
const reachable = await probePort(port);
if (reachable) {
return port;
}
}
return null;
}
private clearPortProbe(id: DevServerId): void {
const timer = this.portProbes.get(id);
if (timer) {
clearTimeout(timer);
this.portProbes.delete(id);
}
}
private sendTerminate(child: ChildProcess): void {
try {
if (this.isWindows) {
if (typeof child.pid === "number") {
process.kill(child.pid);
}
} else {
child.kill("SIGTERM");
}
} catch (error) {
console.warn("[devserver] failed to send SIGTERM", { error });
}
}
private sendKill(child: ChildProcess): void {
try {
if (this.isWindows) {
if (typeof child.pid === "number") {
process.kill(child.pid);
}
} else {
child.kill("SIGKILL");
}
} catch (error) {
console.warn("[devserver] failed to send SIGKILL", { error });
}
}
}
async function probePort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection({ host: "127.0.0.1", port, timeout: PORT_PROBE_TIMEOUT_MS });
let settled = false;
const finish = (reachable: boolean) => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
socket.destroy();
resolve(reachable);
};
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
function waitForClose(child: ChildProcess): Promise<{ timedOut: false; exitCode: number | null }> {
return new Promise((resolve) => {
child.once("close", (exitCode) => {
resolve({ timedOut: false, exitCode });
});
});
}
function delay<T>(ms: number, value: T): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => resolve(value), ms);
});
}
function parseCommand(rawCommand: string): { command: string; args: string[] } {
const input = rawCommand.trim();
if (!input) {
return { command: "", args: [] };
}
const args: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (let index = 0; index < input.length; index += 1) {
const char = input[index] as string;
if ((char === '"' || char === "'") && quote === null) {
quote = char;
continue;
}
if (quote !== null && char === quote) {
quote = null;
continue;
}
if (char === " " && quote === null) {
if (current.length > 0) {
args.push(current);
current = "";
}
continue;
}
current += char;
}
if (current.length > 0) {
args.push(current);
}
const [command = "", ...rest] = args;
return { command, args: rest };
}
const managerInstances = new Map<string, DevServerManager>();
export function getDevServerManager(projectRoot: string): DevServerManager {
const resolvedRoot = path.resolve(projectRoot);
const existing = managerInstances.get(resolvedRoot);
if (existing) {
return existing;
}
const manager = new DevServerManager(resolvedRoot);
managerInstances.set(resolvedRoot, manager);
return manager;
}
export function destroyDevServerManager(projectRoot: string): void {
const resolvedRoot = path.resolve(projectRoot);
const manager = managerInstances.get(resolvedRoot);
if (!manager) {
return;
}
manager.destroy();
managerInstances.delete(resolvedRoot);
}
export function destroyAllDevServerManagers(): void {
for (const manager of managerInstances.values()) {
manager.destroy();
}
managerInstances.clear();
}