feat(FN-2183): add dev server management modules

- Add typed dev server models and constants for configs, session state, runtime metadata, and bounded log history
- Add JSON persistence helpers for .fusion/devserver.json with tolerant parsing and session reconstruction
- Add dev command auto-detection across root and workspace package.json scripts with framework inference and priority sorting
- Implement DevServerManager lifecycle controls, preview URL detection, port probing fallback, and singleton manager helpers
- Add Vitest coverage for devserver types, detection/persistence behavior, and manager lifecycle/events
This commit is contained in:
Fusion
2026-04-20 22:45:26 -07:00
committed by gsxdsm
parent 315892a286
commit e24c87866d
7 changed files with 1395 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
// @vitest-environment node
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import {
PRIORITY_SCRIPTS,
detectDevServerCommands,
detectFramework,
} from "../devserver-detect.js";
import { loadDevServerConfigs, saveDevServerConfigs } from "../devserver-persistence.js";
import { createDevServerId, type DevServerConfig } from "../devserver-types.js";
async function writePackageJson(
projectRoot: string,
payload: Record<string, unknown>,
subpath = "package.json",
): Promise<void> {
const filePath = join(projectRoot, subpath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
}
describe("devserver-detect", () => {
it("detects priority scripts from root package.json", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
await writePackageJson(root, {
scripts: {
dev: "vite",
start: "node server.js",
},
});
const detected = await detectDevServerCommands(root);
expect(detected).toHaveLength(2);
expect(detected[0]?.scriptName).toBe("dev");
expect(detected[1]?.scriptName).toBe("start");
});
it("detectFramework recognizes common dev frameworks", () => {
expect(detectFramework("vite")).toBe("vite");
expect(detectFramework("next dev")).toBe("next");
expect(detectFramework("ng serve --open")).toBe("angular");
});
it("only returns priority scripts", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
await writePackageJson(root, {
scripts: {
dev: "vite",
build: "tsc",
test: "vitest",
},
});
const detected = await detectDevServerCommands(root);
expect(detected).toHaveLength(1);
expect(detected[0]?.scriptName).toBe("dev");
});
it("scans nested package.json files in apps/* and packages/*", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
await writePackageJson(root, { scripts: {} });
await writePackageJson(
root,
{
scripts: {
dev: "next dev",
},
},
"apps/web/package.json",
);
const detected = await detectDevServerCommands(root);
expect(detected).toHaveLength(1);
expect(detected[0]?.cwd).toBe(join(root, "apps", "web"));
expect(detected[0]?.framework).toBe("next");
});
it("sorts results by PRIORITY_SCRIPTS order", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
expect(PRIORITY_SCRIPTS.indexOf("dev")).toBeLessThan(PRIORITY_SCRIPTS.indexOf("serve"));
await writePackageJson(root, {
scripts: {
serve: "serve",
dev: "vite",
},
});
const detected = await detectDevServerCommands(root);
expect(detected).toHaveLength(2);
expect(detected[0]?.scriptName).toBe("dev");
expect(detected[1]?.scriptName).toBe("serve");
});
it("returns empty array when scripts is empty", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
await writePackageJson(root, { scripts: {} });
const detected = await detectDevServerCommands(root);
expect(detected).toEqual([]);
});
it("returns empty array when root package.json is missing", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-detect-"));
const detected = await detectDevServerCommands(root);
expect(detected).toEqual([]);
});
it("persists and reloads devserver configs", async () => {
const root = await mkdtemp(join(tmpdir(), "devserver-config-"));
const configs: DevServerConfig[] = [
{
id: createDevServerId("cfg-1"),
name: "Frontend",
command: "npm run dev",
cwd: root,
env: { NODE_ENV: "development" },
autoStart: true,
},
{
id: createDevServerId("cfg-2"),
name: "Storybook",
command: "npm run storybook",
cwd: root,
},
];
await saveDevServerConfigs(root, configs);
const loaded = await loadDevServerConfigs(root);
expect(loaded).toEqual(configs);
});
});

View File

@@ -0,0 +1,355 @@
// @vitest-environment node
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { spawnMock, createConnectionMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
createConnectionMock: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
vi.mock("node:net", () => ({
createConnection: createConnectionMock,
}));
import {
DevServerManager,
destroyAllDevServerManagers,
} from "../devserver-manager.js";
import { MAX_LOG_ENTRIES, createDevServerId, type DevServerConfig } from "../devserver-types.js";
interface MockSocket extends EventEmitter {
setTimeout: (ms: number) => void;
destroy: () => void;
end: () => void;
}
interface MockChildProcess extends EventEmitter {
pid: number;
killed: boolean;
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
}
function createMockSocket(): MockSocket {
const socket = new EventEmitter() as MockSocket;
socket.setTimeout = vi.fn();
socket.destroy = vi.fn();
socket.end = vi.fn();
return socket;
}
function createMockChildProcess(pid: number): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.pid = pid;
child.killed = false;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn((signal?: string) => {
child.killed = true;
if (signal === "SIGTERM") {
setImmediate(() => {
child.emit("close", 0);
});
}
return true;
});
return child;
}
function makeConfig(id = "dev-1", command = "npm run dev"): DevServerConfig {
return {
id: createDevServerId(id),
name: id,
command,
cwd: "/repo",
};
}
describe("devserver-manager", () => {
let manager: DevServerManager;
let nextPid: number;
let children: MockChildProcess[];
beforeEach(() => {
nextPid = 1000;
children = [];
spawnMock.mockReset();
createConnectionMock.mockReset();
spawnMock.mockImplementation(() => {
const child = createMockChildProcess(nextPid++);
children.push(child);
return child;
});
createConnectionMock.mockImplementation(() => {
const socket = createMockSocket();
queueMicrotask(() => {
socket.emit("error", new Error("closed"));
});
return socket;
});
manager = new DevServerManager("/project");
});
afterEach(() => {
manager.destroy();
destroyAllDevServerManagers();
vi.useRealTimers();
});
it("startServer spawns child process and transitions starting to running on output", async () => {
const config = makeConfig();
const session = await manager.startServer(config);
expect(session.status).toBe("starting");
const child = children[0];
child.stdout.emit("data", "ready");
const updated = manager.getSession(config.id);
expect(updated?.status).toBe("running");
expect(updated?.runtime?.pid).toBe(1000);
expect(Number.isNaN(Date.parse(updated?.runtime?.startedAt ?? ""))).toBe(false);
});
it("captures stdout and stderr as log entries", async () => {
const config = makeConfig();
await manager.startServer(config);
const child = children[0];
child.stdout.emit("data", "line out");
child.stderr.emit("data", "line err");
const logs = manager.getLogs(config.id);
expect(logs).toHaveLength(2);
expect(logs[0]?.stream).toBe("stdout");
expect(logs[0]?.text).toBe("line out");
expect(logs[1]?.stream).toBe("stderr");
expect(logs[1]?.text).toBe("line err");
expect(Number.isNaN(Date.parse(logs[0]?.timestamp ?? ""))).toBe(false);
});
it("bounds log history to MAX_LOG_ENTRIES", async () => {
const config = makeConfig();
await manager.startServer(config);
const child = children[0];
for (let index = 0; index < 600; index += 1) {
child.stdout.emit("data", `line-${index}`);
}
const logs = manager.getLogs(config.id);
expect(logs).toHaveLength(MAX_LOG_ENTRIES);
expect(logs[0]?.text).toBe("line-100");
expect(logs[MAX_LOG_ENTRIES - 1]?.text).toBe("line-599");
});
it("emits log events", async () => {
const config = makeConfig();
const onLog = vi.fn();
manager.on("log", onLog);
await manager.startServer(config);
children[0].stdout.emit("data", "event line");
expect(onLog.mock.calls.length).toBe(1);
expect(onLog.mock.calls[0]?.[0]).toBe(config.id);
expect(onLog.mock.calls[0]?.[1]?.text).toBe("event line");
});
it("auto-detects preview URL from stdout", async () => {
const config = makeConfig();
const onPreview = vi.fn();
manager.on("preview", onPreview);
await manager.startServer(config);
children[0].stdout.emit("data", "Server running at http://localhost:3000");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:3000");
expect(onPreview.mock.calls.length).toBe(1);
expect(onPreview.mock.calls[0]?.[1]).toBe("http://localhost:3000");
});
it("auto-detects preview URL from stderr", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stderr.emit("data", "Listening on http://127.0.0.1:5173");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://127.0.0.1:5173");
});
it("stopServer sends SIGTERM and transitions to stopped", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "ready");
await manager.stopServer(config.id);
expect(children[0].kill).toHaveBeenCalledWith("SIGTERM");
expect(manager.getSession(config.id)?.status).toBe("stopped");
});
it("stopServer escalates to SIGKILL after timeout", async () => {
vi.useFakeTimers();
spawnMock.mockReset();
children = [];
spawnMock.mockImplementation(() => {
const child = createMockChildProcess(nextPid++);
child.kill = vi.fn((signal?: string) => {
if (signal === "SIGKILL") {
setImmediate(() => {
child.emit("close", 0);
});
}
return true;
});
children.push(child);
return child;
});
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "ready");
const stopPromise = manager.stopServer(config.id);
expect(children[0].kill).toHaveBeenCalledWith("SIGTERM");
await vi.advanceTimersByTimeAsync(5001);
expect(children[0].kill).toHaveBeenCalledWith("SIGKILL");
await vi.runAllTimersAsync();
await stopPromise;
});
it("marks session failed on non-zero exit", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].emit("close", 1);
const session = manager.getSession(config.id);
expect(session?.status).toBe("failed");
expect(session?.runtime?.exitCode).toBe(1);
});
it("restartServer stops then starts with same config", async () => {
const config = makeConfig("app", "node dev.js --flag");
await manager.startServer(config);
children[0].stdout.emit("data", "ready");
const restarted = await manager.restartServer(config.id);
expect(children[0].kill).toHaveBeenCalledWith("SIGTERM");
expect(spawnMock.mock.calls.length).toBe(2);
expect(restarted.config.command).toBe("node dev.js --flag");
expect(restarted.config.id).toBe(config.id);
});
it("setPreviewUrl updates session and emits preview event", async () => {
const config = makeConfig();
const onPreview = vi.fn();
manager.on("preview", onPreview);
await manager.startServer(config);
manager.setPreviewUrl(config.id, "http://localhost:8080");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:8080");
expect(onPreview.mock.calls[0]?.[1]).toBe("http://localhost:8080");
});
it("setPreviewUrl(null) clears preview URL", async () => {
const config = makeConfig();
await manager.startServer(config);
manager.setPreviewUrl(config.id, "http://localhost:8080");
manager.setPreviewUrl(config.id, null);
expect(manager.getSession(config.id)?.previewUrl).toBeUndefined();
});
it("getLogs returns full history by default", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "line-1");
children[0].stderr.emit("data", "line-2");
const logs = manager.getLogs(config.id);
expect(logs).toHaveLength(2);
});
it("getLogs tail returns last N entries", async () => {
const config = makeConfig();
await manager.startServer(config);
for (let index = 0; index < 10; index += 1) {
children[0].stdout.emit("data", `line-${index}`);
}
const logs = manager.getLogs(config.id, { tail: 3 });
expect(logs).toHaveLength(3);
expect(logs[0]?.text).toBe("line-7");
expect(logs[2]?.text).toBe("line-9");
});
it("destroy stops all running processes", async () => {
const first = makeConfig("first");
const second = makeConfig("second");
await manager.startServer(first);
await manager.startServer(second);
manager.destroy();
expect(children[0]?.kill).toHaveBeenCalledWith("SIGTERM");
expect(children[1]?.kill).toHaveBeenCalledWith("SIGTERM");
});
it("port probe runs after 10s and sets preview URL from first open port", async () => {
vi.useFakeTimers();
createConnectionMock.mockReset();
createConnectionMock.mockImplementation((options: { port: number }) => {
const socket = createMockSocket();
queueMicrotask(() => {
if (options.port === 4173) {
socket.emit("connect");
} else {
socket.emit("error", new Error("closed"));
}
});
return socket;
});
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "started");
await vi.advanceTimersByTimeAsync(10_001);
expect(createConnectionMock.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:4173");
});
it("port probe is skipped when URL is already detected", async () => {
vi.useFakeTimers();
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "running on http://localhost:3000");
await vi.advanceTimersByTimeAsync(10_001);
expect(createConnectionMock.mock.calls.length).toBe(0);
});
});

View File

@@ -0,0 +1,125 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
MAX_LOG_ENTRIES,
createDevServerId,
type DevServerConfig,
type DevServerLogEntry,
type DevServerSession,
type DevServerSessionMap,
type DevServerStatus,
} from "../devserver-types.js";
describe("devserver-types", () => {
it("createDevServerId returns a branded string", () => {
const id = createDevServerId("server-1");
expect(id).toBe("server-1");
expect(typeof id).toBe("string");
});
it("constructs a complete DevServerSession shape", () => {
const config: DevServerConfig = {
id: createDevServerId("s-1"),
name: "App",
command: "npm run dev",
cwd: "/repo",
env: { NODE_ENV: "development" },
autoStart: true,
};
const session: DevServerSession = {
config,
status: "running",
runtime: {
pid: 1234,
startedAt: new Date().toISOString(),
exitCode: 0,
previewUrl: "http://localhost:3000",
},
previewUrl: "http://localhost:3000",
logHistory: [
{
timestamp: new Date().toISOString(),
stream: "stdout",
text: "ready",
},
],
};
expect(session.config.name).toBe("App");
expect(session.status).toBe("running");
expect(session.runtime?.pid).toBe(1234);
expect(session.logHistory[0]?.stream).toBe("stdout");
});
it("DevServerSessionMap behaves as a map of id -> session", () => {
const map: DevServerSessionMap = new Map();
const id = createDevServerId("server-a");
map.set(id, {
config: {
id,
name: "A",
command: "vite",
cwd: "/repo",
},
status: "stopped",
logHistory: [],
});
expect(map.get(id)?.config.command).toBe("vite");
expect(Array.from(map.keys())).toEqual(["server-a"]);
});
it("MAX_LOG_ENTRIES is 500", () => {
expect(MAX_LOG_ENTRIES).toBe(500);
});
it("all DevServerStatus values are assignable", () => {
const statuses: DevServerStatus[] = ["stopped", "starting", "running", "failed", "stopping"];
expect(statuses).toHaveLength(5);
expect(statuses.includes("failed")).toBe(true);
});
it("DevServerConfig supports optional fields omitted and included", () => {
const minimal: DevServerConfig = {
id: createDevServerId("minimal"),
name: "Minimal",
command: "npm run dev",
cwd: "/repo",
};
const expanded: DevServerConfig = {
id: createDevServerId("expanded"),
name: "Expanded",
command: "npm run dev",
cwd: "/repo",
env: { PORT: "3000" },
autoStart: false,
};
expect(minimal.env).toBeUndefined();
expect(expanded.env?.PORT).toBe("3000");
expect(expanded.autoStart).toBe(false);
});
it("DevServerLogEntry supports stdout and stderr streams", () => {
const stdoutEntry: DevServerLogEntry = {
timestamp: new Date().toISOString(),
stream: "stdout",
text: "ok",
};
const stderrEntry: DevServerLogEntry = {
timestamp: new Date().toISOString(),
stream: "stderr",
text: "error",
};
expect(stdoutEntry.stream).toBe("stdout");
expect(stderrEntry.stream).toBe("stderr");
});
});

View File

@@ -0,0 +1,165 @@
import { exec } from "node:child_process";
import { readdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
const execAsync = promisify(exec);
export interface DetectedCommand {
name: string;
command: string;
cwd: string;
scriptName: string;
packagePath: string;
framework?: string;
}
interface PackageJsonShape {
scripts?: Record<string, string>;
}
export const PRIORITY_SCRIPTS = ["dev", "start", "web", "frontend", "serve", "storybook"];
export const FRAMEWORK_PATTERNS: Record<string, RegExp> = {
vite: /\bvite\b/,
next: /\bnext\b/,
nuxt: /\bnuxt\b/,
remix: /\bremix\b/,
astro: /\bastro\b/,
storybook: /\bstorybook\b/,
angular: /\bng\s+serve\b/,
"react-scripts": /\breact-scripts\b/,
"create-react-app": /\breact-scripts\s+start\b/,
};
export function detectFramework(scriptCommand: string): string | undefined {
for (const [framework, pattern] of Object.entries(FRAMEWORK_PATTERNS)) {
if (pattern.test(scriptCommand)) {
return framework;
}
}
return undefined;
}
async function readPackageJson(packagePath: string): Promise<PackageJsonShape | null> {
try {
const escapedPath = JSON.stringify(packagePath);
const { stdout } = await execAsync(
`node -e 'process.stdout.write(require("node:fs").readFileSync(${escapedPath}, "utf8"))'`,
{ maxBuffer: 1024 * 1024 },
);
return JSON.parse(stdout) as PackageJsonShape;
} catch {
return null;
}
}
function buildDetectedCommand(
scriptName: string,
scriptCommand: string,
cwd: string,
packagePath: string,
): DetectedCommand {
return {
name: `Dev Server (${scriptName})`,
command: `npm run ${scriptName}`,
cwd,
scriptName,
packagePath,
framework: detectFramework(scriptCommand),
};
}
async function findWorkspacePackageJsons(projectRoot: string): Promise<string[]> {
const packageFiles: string[] = [];
for (const rootFolder of ["apps", "packages"]) {
const rootPath = join(projectRoot, rootFolder);
try {
const entries = await readdir(rootPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
packageFiles.push(join(rootPath, entry.name, "package.json"));
}
} catch {
// Ignore missing workspace directories.
}
}
return packageFiles;
}
function collectFromScripts(
scripts: Record<string, string> | undefined,
cwd: string,
packagePath: string,
): DetectedCommand[] {
if (!scripts) {
return [];
}
const commands: DetectedCommand[] = [];
for (const scriptName of PRIORITY_SCRIPTS) {
const scriptCommand = scripts[scriptName];
if (typeof scriptCommand !== "string" || scriptCommand.trim().length === 0) {
continue;
}
commands.push(buildDetectedCommand(scriptName, scriptCommand, cwd, packagePath));
}
return commands;
}
export async function detectDevServerCommands(projectRoot: string): Promise<DetectedCommand[]> {
const root = resolve(projectRoot);
const deduped = new Map<string, DetectedCommand>();
const rootPackagePath = join(root, "package.json");
const rootPackageJson = await readPackageJson(rootPackagePath);
if (!rootPackageJson) {
return [];
}
for (const command of collectFromScripts(rootPackageJson.scripts, root, rootPackagePath)) {
deduped.set(`${command.cwd}::${command.scriptName}`, command);
}
const workspacePackageFiles = await findWorkspacePackageJsons(root);
for (const packagePath of workspacePackageFiles) {
const pkg = await readPackageJson(packagePath);
if (!pkg) {
continue;
}
const packageCwd = dirname(packagePath);
const commands = collectFromScripts(pkg.scripts, packageCwd, packagePath);
for (const command of commands) {
deduped.set(`${command.cwd}::${command.scriptName}`, command);
}
}
const results = Array.from(deduped.values());
results.sort((a, b) => {
const aPriority = PRIORITY_SCRIPTS.indexOf(a.scriptName);
const bPriority = PRIORITY_SCRIPTS.indexOf(b.scriptName);
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
const aDepth = a.cwd === root ? 0 : a.cwd.split(/[\\/]/).length;
const bDepth = b.cwd === root ? 0 : b.cwd.split(/[\\/]/).length;
if (aDepth !== bDepth) {
return aDepth - bDepth;
}
return a.cwd.localeCompare(b.cwd);
});
return results;
}

View File

@@ -0,0 +1,469 @@
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) {
// eslint-disable-next-line no-await-in-loop
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();
}

View File

@@ -0,0 +1,100 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import {
createDevServerId,
type DevServerConfig,
type DevServerSession,
} from "./devserver-types.js";
interface PersistenceData {
configs: DevServerConfig[];
}
export function projectDevServerFile(projectDir: string): string {
return join(resolve(projectDir), ".fusion", "devserver.json");
}
function parseEnv(value: unknown): Record<string, string> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const parsed: Record<string, string> = {};
for (const [key, candidate] of Object.entries(value)) {
if (typeof candidate === "string") {
parsed[key] = candidate;
}
}
return Object.keys(parsed).length > 0 ? parsed : undefined;
}
function parseConfig(candidate: unknown): DevServerConfig | null {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
return null;
}
const value = candidate as Record<string, unknown>;
if (typeof value.id !== "string") {
return null;
}
if (typeof value.name !== "string" || typeof value.command !== "string" || typeof value.cwd !== "string") {
return null;
}
return {
id: createDevServerId(value.id),
name: value.name,
command: value.command,
cwd: value.cwd,
env: parseEnv(value.env),
autoStart: typeof value.autoStart === "boolean" ? value.autoStart : undefined,
};
}
export async function loadDevServerConfigs(projectDir: string): Promise<DevServerConfig[]> {
const filePath = projectDevServerFile(projectDir);
try {
const raw = await readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<PersistenceData>;
if (!Array.isArray(parsed.configs)) {
return [];
}
return parsed.configs
.map((config) => parseConfig(config))
.filter((config): config is DevServerConfig => config !== null);
} catch {
return [];
}
}
export async function saveDevServerConfigs(projectDir: string, configs: DevServerConfig[]): Promise<void> {
const filePath = projectDevServerFile(projectDir);
const folder = dirname(filePath);
try {
await mkdir(folder, { recursive: true });
const payload: PersistenceData = { configs };
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
} catch {
// Graceful no-op: callers can continue operating with in-memory state.
}
}
export function reconstructSessions(configs: DevServerConfig[]): Map<string, DevServerSession> {
const sessions = new Map<string, DevServerSession>();
for (const config of configs) {
sessions.set(config.id, {
config,
status: "stopped",
logHistory: [],
});
}
return sessions;
}
export type { PersistenceData };

View File

@@ -0,0 +1,41 @@
export type DevServerId = string & { readonly __brand: unique symbol };
export function createDevServerId(id: string): DevServerId {
return id as DevServerId;
}
export type DevServerStatus = "stopped" | "starting" | "running" | "failed" | "stopping";
export interface DevServerConfig {
id: DevServerId;
name: string;
command: string;
cwd: string;
env?: Record<string, string>;
autoStart?: boolean;
}
export interface DevServerRuntime {
pid: number;
startedAt: string;
exitCode?: number;
previewUrl?: string;
}
export interface DevServerLogEntry {
timestamp: string;
stream: "stdout" | "stderr";
text: string;
}
export interface DevServerSession {
config: DevServerConfig;
status: DevServerStatus;
runtime?: DevServerRuntime;
previewUrl?: string;
logHistory: DevServerLogEntry[];
}
export const MAX_LOG_ENTRIES = 500;
export type DevServerSessionMap = Map<string, DevServerSession>;