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:
140
packages/dashboard/src/__tests__/devserver-detect.test.ts
Normal file
140
packages/dashboard/src/__tests__/devserver-detect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
355
packages/dashboard/src/__tests__/devserver-manager.test.ts
Normal file
355
packages/dashboard/src/__tests__/devserver-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
125
packages/dashboard/src/__tests__/devserver-types.test.ts
Normal file
125
packages/dashboard/src/__tests__/devserver-types.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
165
packages/dashboard/src/devserver-detect.ts
Normal file
165
packages/dashboard/src/devserver-detect.ts
Normal 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;
|
||||
}
|
||||
469
packages/dashboard/src/devserver-manager.ts
Normal file
469
packages/dashboard/src/devserver-manager.ts
Normal 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();
|
||||
}
|
||||
100
packages/dashboard/src/devserver-persistence.ts
Normal file
100
packages/dashboard/src/devserver-persistence.ts
Normal 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 };
|
||||
41
packages/dashboard/src/devserver-types.ts
Normal file
41
packages/dashboard/src/devserver-types.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user