feat(FN-2178): add dashboard dev server management flow
- Add DevServerManager service to start/stop/restart dev processes, persist state, stream logs, and detect preview URLs - Add dev server candidate detection across root/workspace package scripts with caching and invalidation helpers - Expose /api/dev-server routes for candidates, status, lifecycle actions, manual preview URL, and SSE log/status stream - Initialize and tear down dev server managers from dashboard server lifecycle - Add unit and route tests covering detection, manager lifecycle, URL parsing/fallback probes, and API validation
This commit is contained in:
189
packages/dashboard/src/__tests__/dev-server-detect.test.ts
Normal file
189
packages/dashboard/src/__tests__/dev-server-detect.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectDevServerCandidates,
|
||||
EXCLUDED_SCRIPT_NAMES,
|
||||
invalidateDetectionCache,
|
||||
PREFERRED_SCRIPT_NAMES,
|
||||
} from "../dev-server-detect.js";
|
||||
import { FALLBACK_PORTS } from "../dev-server-manager.js";
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
describe("detectDevServerCandidates", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(path.join(os.tmpdir(), "fn-dev-detect-"));
|
||||
invalidateDetectionCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
invalidateDetectionCache();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("detects preferred scripts from root package.json and excludes lint scripts", async () => {
|
||||
writeJson(path.join(tempDir, "package.json"), {
|
||||
scripts: {
|
||||
dev: "vite",
|
||||
start: "next dev",
|
||||
lint: "eslint .",
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = await detectDevServerCandidates(tempDir);
|
||||
|
||||
expect(candidates.map((candidate) => candidate.scriptName)).toEqual(["dev", "start"]);
|
||||
expect(candidates[0]).toMatchObject({
|
||||
scriptName: "dev",
|
||||
label: "Root > dev",
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(candidates[1]).toMatchObject({
|
||||
scriptName: "start",
|
||||
label: "Root > start",
|
||||
cwd: tempDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when package.json is missing", async () => {
|
||||
await expect(detectDevServerCandidates(tempDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when package.json has invalid json", async () => {
|
||||
writeFileSync(path.join(tempDir, "package.json"), "{ invalid json", "utf-8");
|
||||
await expect(detectDevServerCandidates(tempDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when scripts field is absent", async () => {
|
||||
writeJson(path.join(tempDir, "package.json"), { name: "demo" });
|
||||
await expect(detectDevServerCandidates(tempDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("detects workspace candidates from pnpm-workspace.yaml", async () => {
|
||||
writeJson(path.join(tempDir, "package.json"), { name: "repo" });
|
||||
writeFileSync(path.join(tempDir, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n", "utf-8");
|
||||
|
||||
const workspaceDir = path.join(tempDir, "packages", "web");
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
writeJson(path.join(workspaceDir, "package.json"), {
|
||||
scripts: {
|
||||
dev: "vite",
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = await detectDevServerCandidates(tempDir);
|
||||
expect(candidates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
scriptName: "dev",
|
||||
label: "packages/web > dev",
|
||||
cwd: workspaceDir,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects workspace candidates from npm workspaces array", async () => {
|
||||
writeJson(path.join(tempDir, "package.json"), {
|
||||
workspaces: ["apps/*"],
|
||||
});
|
||||
|
||||
const clientDir = path.join(tempDir, "apps", "client");
|
||||
mkdirSync(clientDir, { recursive: true });
|
||||
writeJson(path.join(clientDir, "package.json"), {
|
||||
scripts: {
|
||||
dev: "vite",
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = await detectDevServerCandidates(tempDir);
|
||||
expect(candidates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
scriptName: "dev",
|
||||
label: "apps/client > dev",
|
||||
cwd: clientDir,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects workspace candidates from npm workspaces object", async () => {
|
||||
writeJson(path.join(tempDir, "package.json"), {
|
||||
workspaces: {
|
||||
packages: ["apps/*"],
|
||||
},
|
||||
});
|
||||
|
||||
const clientDir = path.join(tempDir, "apps", "client");
|
||||
mkdirSync(clientDir, { recursive: true });
|
||||
writeJson(path.join(clientDir, "package.json"), {
|
||||
scripts: {
|
||||
dev: "vite",
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = await detectDevServerCandidates(tempDir);
|
||||
expect(candidates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
scriptName: "dev",
|
||||
label: "apps/client > dev",
|
||||
cwd: clientDir,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("orders candidates by preferred script priority", async () => {
|
||||
writeJson(path.join(tempDir, "package.json"), {
|
||||
scripts: {
|
||||
preview: "vite preview",
|
||||
storybook: "storybook dev -p 6006",
|
||||
frontend: "vite",
|
||||
web: "vite",
|
||||
serve: "vite serve",
|
||||
start: "next start",
|
||||
dev: "vite dev",
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = await detectDevServerCandidates(tempDir);
|
||||
expect(candidates.map((candidate) => candidate.scriptName)).toEqual([...PREFERRED_SCRIPT_NAMES]);
|
||||
});
|
||||
|
||||
it("invalidates cache when package.json mtime changes", async () => {
|
||||
const packageJsonPath = path.join(tempDir, "package.json");
|
||||
writeJson(packageJsonPath, { scripts: { dev: "vite" } });
|
||||
|
||||
const firstRun = await detectDevServerCandidates(tempDir);
|
||||
expect(firstRun.map((candidate) => candidate.scriptName)).toEqual(["dev"]);
|
||||
|
||||
writeJson(packageJsonPath, { scripts: { start: "next dev" } });
|
||||
const now = new Date();
|
||||
utimesSync(packageJsonPath, now, new Date(now.getTime() + 5_000));
|
||||
|
||||
const secondRun = await detectDevServerCandidates(tempDir);
|
||||
expect(secondRun.map((candidate) => candidate.scriptName)).toEqual(["start"]);
|
||||
});
|
||||
|
||||
it("supports explicit cache invalidation", async () => {
|
||||
const packageJsonPath = path.join(tempDir, "package.json");
|
||||
writeJson(packageJsonPath, { scripts: { dev: "vite" } });
|
||||
|
||||
await detectDevServerCandidates(tempDir);
|
||||
|
||||
writeJson(packageJsonPath, { scripts: { serve: "vite" } });
|
||||
invalidateDetectionCache(tempDir);
|
||||
|
||||
const refreshed = await detectDevServerCandidates(tempDir);
|
||||
expect(refreshed.map((candidate) => candidate.scriptName)).toEqual(["serve"]);
|
||||
});
|
||||
|
||||
it("never includes reserved dashboard port 4040 in fallback ports", () => {
|
||||
expect(EXCLUDED_SCRIPT_NAMES.has("lint")).toBe(true);
|
||||
expect(FALLBACK_PORTS.includes(4040 as never)).toBe(false);
|
||||
});
|
||||
});
|
||||
408
packages/dashboard/src/__tests__/dev-server-manager.test.ts
Normal file
408
packages/dashboard/src/__tests__/dev-server-manager.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import {
|
||||
DevServerManager,
|
||||
FALLBACK_PORTS,
|
||||
MAX_LOG_LINES,
|
||||
destroyAllDevServerManagers,
|
||||
parseLineForUrl,
|
||||
} from "../dev-server-manager.js";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
class MockChildProcess extends EventEmitter {
|
||||
public readonly stdout = new EventEmitter();
|
||||
public readonly stderr = new EventEmitter();
|
||||
public readonly kill = vi.fn((signal: NodeJS.Signals = "SIGTERM") => {
|
||||
this.killSignals.push(signal);
|
||||
|
||||
if (signal === "SIGTERM" && this.ignoreSigterm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.emit("exit", 0);
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
public readonly killSignals: NodeJS.Signals[] = [];
|
||||
|
||||
constructor(
|
||||
public readonly pid: number,
|
||||
private readonly ignoreSigterm = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
emitStdout(text: string): void {
|
||||
this.stdout.emit("data", Buffer.from(`${text}\n`, "utf-8"));
|
||||
}
|
||||
|
||||
emitStderr(text: string): void {
|
||||
this.stderr.emit("data", Buffer.from(`${text}\n`, "utf-8"));
|
||||
}
|
||||
|
||||
emitExit(code: number | null): void {
|
||||
this.emit("exit", code);
|
||||
}
|
||||
|
||||
emitError(message: string): void {
|
||||
this.emit("error", new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
const spawnMock = vi.mocked(spawn);
|
||||
|
||||
async function waitForCondition(check: () => boolean | Promise<boolean>, timeoutMs = 1500): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (await check()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
describe("DevServerManager", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), "fn-dev-server-manager-"));
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
destroyAllDevServerManagers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("handles start lifecycle transitions and captures logs", async () => {
|
||||
const child = new MockChildProcess(12345);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
const startState = await manager.start("echo hello", "test", tempDir);
|
||||
|
||||
expect(startState.status).toBe("starting");
|
||||
expect(startState.command).toBe("echo hello");
|
||||
expect(startState.scriptName).toBe("test");
|
||||
expect(startState.cwd).toBe(tempDir);
|
||||
expect(startState.pid).toBe(12345);
|
||||
expect(typeof startState.startedAt).toBe("string");
|
||||
|
||||
child.emitStdout("hello from stdout");
|
||||
expect(manager.getState().status).toBe("running");
|
||||
expect(manager.getLogs()).toContain("hello from stdout");
|
||||
|
||||
child.emitExit(0);
|
||||
await waitForCondition(() => manager.getState().status === "stopped");
|
||||
expect(manager.getState().exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("throws when start is called while already active", async () => {
|
||||
const child = new MockChildProcess(12121);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
child.emitStdout("running");
|
||||
|
||||
await expect(manager.start("npm run dev", "dev", tempDir)).rejects.toThrow("Dev server is already running");
|
||||
});
|
||||
|
||||
it("stops a running process with SIGTERM", async () => {
|
||||
const child = new MockChildProcess(23456);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("node server.js", "dev", tempDir);
|
||||
child.emitStdout("booting");
|
||||
|
||||
const state = await manager.stop();
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(state.status).toBe("stopped");
|
||||
|
||||
await waitForCondition(() => manager.getState().status === "stopped");
|
||||
});
|
||||
|
||||
it("sends SIGKILL fallback when process ignores SIGTERM", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const child = new MockChildProcess(34567, true);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("node stubborn.js", "dev", tempDir);
|
||||
child.emitStdout("running");
|
||||
|
||||
await manager.stop();
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_100);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("restarts using stored command/scriptName/cwd", async () => {
|
||||
const first = new MockChildProcess(45678);
|
||||
const second = new MockChildProcess(56789);
|
||||
spawnMock
|
||||
.mockReturnValueOnce(first as unknown as ChildProcess)
|
||||
.mockReturnValueOnce(second as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
first.emitStdout("ready");
|
||||
|
||||
const restarted = await manager.restart();
|
||||
expect(first.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"npm run dev",
|
||||
[],
|
||||
expect.objectContaining({ cwd: tempDir, shell: true }),
|
||||
);
|
||||
expect(restarted.pid).toBe(56789);
|
||||
});
|
||||
|
||||
it("throws when restart is called before initial start", async () => {
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await expect(manager.restart()).rejects.toThrow("Cannot restart dev server before it has been started once");
|
||||
});
|
||||
|
||||
it("returns all server states", () => {
|
||||
const manager = new DevServerManager(tempDir);
|
||||
const states = manager.getAllStates();
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0]?.id).toBe("default");
|
||||
});
|
||||
|
||||
it("parses known URL log patterns", () => {
|
||||
expect(parseLineForUrl(" > Local: http://localhost:5173/")).toEqual({
|
||||
url: "http://localhost:5173",
|
||||
port: 5173,
|
||||
});
|
||||
expect(parseLineForUrl("ready on http://localhost:3000")).toEqual({
|
||||
url: "http://localhost:3000",
|
||||
port: 3000,
|
||||
});
|
||||
expect(parseLineForUrl("Local: http://localhost:6006")).toEqual({
|
||||
url: "http://localhost:6006",
|
||||
port: 6006,
|
||||
});
|
||||
expect(parseLineForUrl("http://0.0.0.0:8080")).toEqual({
|
||||
url: "http://localhost:8080",
|
||||
port: 8080,
|
||||
});
|
||||
expect(parseLineForUrl("listening on 127.0.0.1:4173")).toEqual({
|
||||
url: "http://localhost:4173",
|
||||
port: 4173,
|
||||
});
|
||||
expect(parseLineForUrl("no url here")).toBeNull();
|
||||
});
|
||||
|
||||
it("detects URL from output and emits url-detected event", async () => {
|
||||
const child = new MockChildProcess(67890);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
const onDetected = vi.fn();
|
||||
manager.on("url-detected", onDetected);
|
||||
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
child.emitStdout("Local: http://localhost:5173");
|
||||
|
||||
await waitForCondition(() => manager.getState().previewUrl === "http://localhost:5173");
|
||||
expect(manager.getState().detectedPort).toBe(5173);
|
||||
expect(onDetected).toHaveBeenCalledWith({
|
||||
serverId: "default",
|
||||
url: "http://localhost:5173",
|
||||
port: 5173,
|
||||
});
|
||||
});
|
||||
|
||||
it("schedules fallback probing after 10 seconds when no URL is detected", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const child = new MockChildProcess(78901);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
const probeSpy = vi.spyOn(manager as unknown as { probeFallbackPorts: () => void }, "probeFallbackPorts");
|
||||
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
child.emitStdout("server booted");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_100);
|
||||
expect(probeSpy).toHaveBeenCalled();
|
||||
expect(FALLBACK_PORTS.includes(4040 as never)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects a running server through fallback port probing", async () => {
|
||||
const child = new MockChildProcess(78902);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const server = createServer((_req, res) => {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(3000, () => resolve()));
|
||||
|
||||
try {
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
child.emitStdout("server booted");
|
||||
|
||||
(manager as unknown as { probeFallbackPorts: () => void }).probeFallbackPorts();
|
||||
await waitForCondition(() => manager.getState().detectedPort === 3000, 3_000);
|
||||
|
||||
expect(manager.getState().previewUrl).toBe("http://localhost:3000");
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("persists state and reconnects when existing PID is alive", async () => {
|
||||
const child = new MockChildProcess(89012);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
|
||||
const stateFile = path.join(tempDir, ".fusion", "dev-server.json");
|
||||
await waitForCondition(async () => {
|
||||
try {
|
||||
await readFile(stateFile, "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: number | NodeJS.Signals) => {
|
||||
if (signal === 0 && pid === 89012) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
}) as typeof process.kill);
|
||||
|
||||
const reloaded = new DevServerManager(tempDir);
|
||||
await reloaded.stop();
|
||||
|
||||
expect(reloaded.getState().status).toBe("running");
|
||||
expect(reloaded.getState().pid).toBe(89012);
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("marks persisted dead PID as stopped", async () => {
|
||||
const stateFile = path.join(tempDir, ".fusion", "dev-server.json");
|
||||
await mkdir(path.dirname(stateFile), { recursive: true });
|
||||
await writeFile(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
id: "default",
|
||||
name: "default",
|
||||
command: "npm run dev",
|
||||
scriptName: "dev",
|
||||
cwd: tempDir,
|
||||
pid: 999999,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: number | NodeJS.Signals) => {
|
||||
if (signal === 0 && pid === 999999) {
|
||||
throw Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
}) as typeof process.kill);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.stop();
|
||||
|
||||
expect(manager.getState().status).toBe("stopped");
|
||||
expect(manager.getState().pid).toBeUndefined();
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("supports manual preview URL override and reset", async () => {
|
||||
const child = new MockChildProcess(90123);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
child.emitStdout("ready on http://localhost:5173");
|
||||
|
||||
await waitForCondition(() => manager.getState().detectedPort === 5173);
|
||||
|
||||
manager.setManualPreviewUrl("http://example.com:9999");
|
||||
expect(manager.getState().manualPreviewUrl).toBe("http://example.com:9999");
|
||||
expect(manager.getState().previewUrl).toBe("http://example.com:9999");
|
||||
|
||||
manager.setManualPreviewUrl(null);
|
||||
expect(manager.getState().manualPreviewUrl).toBeUndefined();
|
||||
expect(manager.getState().previewUrl).toBe("http://localhost:5173");
|
||||
});
|
||||
|
||||
it("keeps logs in a 500-line ring buffer", async () => {
|
||||
const child = new MockChildProcess(11223);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
|
||||
for (let index = 0; index < MAX_LOG_LINES + 25; index += 1) {
|
||||
child.emitStdout(`line-${index}`);
|
||||
}
|
||||
|
||||
const logs = manager.getLogs();
|
||||
expect(logs).toHaveLength(MAX_LOG_LINES);
|
||||
expect(logs[0]).toBe("line-25");
|
||||
expect(logs.at(-1)).toBe(`line-${MAX_LOG_LINES + 24}`);
|
||||
expect(manager.getLogs(10)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("destroy() kills running processes and clears internal timers/maps", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const child = new MockChildProcess(22334, true);
|
||||
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
|
||||
const manager = new DevServerManager(tempDir);
|
||||
await manager.start("npm run dev", "dev", tempDir);
|
||||
child.emitStdout("running");
|
||||
|
||||
manager.destroy();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect((manager as unknown as { processes: Map<string, ChildProcess> }).processes.size).toBe(0);
|
||||
expect((manager as unknown as { servers: Map<string, unknown> }).servers.size).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_100);
|
||||
expect(child.kill).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
297
packages/dashboard/src/__tests__/dev-server-routes.test.ts
Normal file
297
packages/dashboard/src/__tests__/dev-server-routes.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import { detectDevServerCandidates, invalidateDetectionCache } from "../dev-server-detect.js";
|
||||
import { getDevServerManager } from "../dev-server-manager.js";
|
||||
|
||||
vi.mock("../dev-server-manager.js", () => ({
|
||||
getDevServerManager: vi.fn(),
|
||||
destroyAllDevServerManagers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../dev-server-detect.js", () => ({
|
||||
detectDevServerCandidates: vi.fn(),
|
||||
invalidateDetectionCache: vi.fn(),
|
||||
}));
|
||||
|
||||
type MockDevServerState = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "stopped" | "starting" | "running" | "failed";
|
||||
command: string;
|
||||
scriptName: string;
|
||||
cwd: string;
|
||||
logs: string[];
|
||||
previewUrl?: string;
|
||||
manualPreviewUrl?: string;
|
||||
};
|
||||
|
||||
function createMockStore(rootDir: string): TaskStore {
|
||||
return {
|
||||
getRootDir: vi.fn().mockReturnValue(rootDir),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function buildApp(store: TaskStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
async function GET(app: express.Express, pathName: string) {
|
||||
return performGet(app, pathName);
|
||||
}
|
||||
|
||||
async function POST_JSON(app: express.Express, pathName: string, payload: unknown) {
|
||||
return performRequest(app, "POST", pathName, JSON.stringify(payload), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
describe("dev-server routes", () => {
|
||||
let tempDir: string;
|
||||
let app: express.Express;
|
||||
let state: MockDevServerState;
|
||||
let manager: {
|
||||
getState: ReturnType<typeof vi.fn>;
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
restart: ReturnType<typeof vi.fn>;
|
||||
setManualPreviewUrl: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const detectCandidatesMock = vi.mocked(detectDevServerCandidates);
|
||||
const invalidateDetectionCacheMock = vi.mocked(invalidateDetectionCache);
|
||||
const getDevServerManagerMock = vi.mocked(getDevServerManager);
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(path.join(os.tmpdir(), "fn-dev-server-routes-"));
|
||||
writeFileSync(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ scripts: { dev: "vite" } }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
state = {
|
||||
id: "default",
|
||||
name: "default",
|
||||
status: "stopped",
|
||||
command: "",
|
||||
scriptName: "",
|
||||
cwd: tempDir,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
manager = {
|
||||
getState: vi.fn(() => structuredClone(state)),
|
||||
start: vi.fn(async (command: string, scriptName: string, cwd?: string) => {
|
||||
state = {
|
||||
...state,
|
||||
status: "starting",
|
||||
command,
|
||||
scriptName,
|
||||
cwd: cwd ?? tempDir,
|
||||
};
|
||||
return structuredClone(state);
|
||||
}),
|
||||
stop: vi.fn(async () => {
|
||||
state = {
|
||||
...state,
|
||||
status: "stopped",
|
||||
};
|
||||
return structuredClone(state);
|
||||
}),
|
||||
restart: vi.fn(async () => {
|
||||
state = {
|
||||
...state,
|
||||
status: "running",
|
||||
};
|
||||
return structuredClone(state);
|
||||
}),
|
||||
setManualPreviewUrl: vi.fn((url: string | null) => {
|
||||
state = {
|
||||
...state,
|
||||
manualPreviewUrl: url ?? undefined,
|
||||
previewUrl: url ?? undefined,
|
||||
};
|
||||
return structuredClone(state);
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
|
||||
detectCandidatesMock.mockReset();
|
||||
invalidateDetectionCacheMock.mockReset();
|
||||
getDevServerManagerMock.mockReset();
|
||||
|
||||
detectCandidatesMock.mockResolvedValue([
|
||||
{
|
||||
name: "dev",
|
||||
command: "vite",
|
||||
scriptName: "dev",
|
||||
cwd: tempDir,
|
||||
label: "Root > dev",
|
||||
},
|
||||
]);
|
||||
|
||||
getDevServerManagerMock.mockReturnValue(manager as never);
|
||||
|
||||
app = buildApp(createMockStore(tempDir));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("GET /api/dev-server/candidates returns detected candidates", async () => {
|
||||
const res = await GET(app, "/api/dev-server/candidates");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body).toContainEqual(
|
||||
expect.objectContaining({
|
||||
scriptName: "dev",
|
||||
}),
|
||||
);
|
||||
expect(detectCandidatesMock).toHaveBeenCalledWith(tempDir);
|
||||
});
|
||||
|
||||
it("GET /api/dev-server/status returns server state", async () => {
|
||||
const res = await GET(app, "/api/dev-server/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "default",
|
||||
name: "default",
|
||||
status: expect.any(String),
|
||||
command: expect.any(String),
|
||||
logs: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/start starts a server", async () => {
|
||||
const res = await POST_JSON(app, "/api/dev-server/start", {
|
||||
command: "echo test",
|
||||
scriptName: "test",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).not.toBe("stopped");
|
||||
expect(res.body.command).toBe("echo test");
|
||||
expect(manager.start).toHaveBeenCalledWith("echo test", "test", undefined);
|
||||
expect(invalidateDetectionCacheMock).toHaveBeenCalledWith(tempDir);
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/start validates missing command", async () => {
|
||||
const res = await POST_JSON(app, "/api/dev-server/start", {
|
||||
scriptName: "test",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/start validates missing scriptName", async () => {
|
||||
const res = await POST_JSON(app, "/api/dev-server/start", {
|
||||
command: "echo test",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/stop stops the server", async () => {
|
||||
await POST_JSON(app, "/api/dev-server/start", {
|
||||
command: "echo test",
|
||||
scriptName: "test",
|
||||
});
|
||||
|
||||
const res = await POST_JSON(app, "/api/dev-server/stop", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("stopped");
|
||||
expect(manager.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/restart restarts the server", async () => {
|
||||
await POST_JSON(app, "/api/dev-server/start", {
|
||||
command: "echo test",
|
||||
scriptName: "test",
|
||||
});
|
||||
|
||||
const res = await POST_JSON(app, "/api/dev-server/restart", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("running");
|
||||
expect(manager.restart).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/preview-url sets manual preview URL", async () => {
|
||||
const res = await POST_JSON(app, "/api/dev-server/preview-url", {
|
||||
url: "http://localhost:9999",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.manualPreviewUrl).toBe("http://localhost:9999");
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/preview-url accepts null URL", async () => {
|
||||
const res = await POST_JSON(app, "/api/dev-server/preview-url", {
|
||||
url: null,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.manualPreviewUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("POST /api/dev-server/preview-url validates URL type", async () => {
|
||||
const res = await POST_JSON(app, "/api/dev-server/preview-url", {
|
||||
url: 123,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /api/dev-server/logs/stream exposes SSE headers and connected event", async () => {
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/api/dev-server/logs/stream`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const firstChunk = await reader?.read();
|
||||
const text = firstChunk?.value ? new TextDecoder().decode(firstChunk.value) : "";
|
||||
|
||||
expect(text).toContain("event: connected");
|
||||
await reader?.cancel();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
253
packages/dashboard/src/dev-server-detect.ts
Normal file
253
packages/dashboard/src/dev-server-detect.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { glob, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export interface DetectedCandidate {
|
||||
name: string;
|
||||
command: string;
|
||||
scriptName: string;
|
||||
cwd: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const PREFERRED_SCRIPT_NAMES: readonly string[] = [
|
||||
"dev",
|
||||
"start",
|
||||
"serve",
|
||||
"web",
|
||||
"frontend",
|
||||
"storybook",
|
||||
"preview",
|
||||
];
|
||||
|
||||
export const EXCLUDED_SCRIPT_NAMES: ReadonlySet<string> = new Set([
|
||||
"lint",
|
||||
"test",
|
||||
"build",
|
||||
"typecheck",
|
||||
"check",
|
||||
"clean",
|
||||
"format",
|
||||
"validate",
|
||||
"compile",
|
||||
"bundle",
|
||||
]);
|
||||
|
||||
const cache: Map<string, { candidates: DetectedCandidate[]; mtime: number }> = new Map();
|
||||
|
||||
interface PackageJsonShape {
|
||||
scripts?: Record<string, string>;
|
||||
workspaces?: string[] | { packages?: string[] };
|
||||
}
|
||||
|
||||
function parseScripts(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const scripts = (value as { scripts?: unknown }).scripts;
|
||||
if (!scripts || typeof scripts !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const entries = Object.entries(scripts as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function shouldIncludeScript(scriptName: string): boolean {
|
||||
if (PREFERRED_SCRIPT_NAMES.includes(scriptName)) {
|
||||
return true;
|
||||
}
|
||||
return !EXCLUDED_SCRIPT_NAMES.has(scriptName);
|
||||
}
|
||||
|
||||
function preferredIndex(scriptName: string): number {
|
||||
const index = PREFERRED_SCRIPT_NAMES.indexOf(scriptName);
|
||||
return index >= 0 ? index : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function buildCandidatesForScripts(
|
||||
scripts: Record<string, string>,
|
||||
cwd: string,
|
||||
labelPrefix: string,
|
||||
): DetectedCandidate[] {
|
||||
return Object.entries(scripts)
|
||||
.filter(([scriptName]) => shouldIncludeScript(scriptName))
|
||||
.map(([scriptName, command]) => {
|
||||
const label = `${labelPrefix} > ${scriptName}`;
|
||||
const isRoot = labelPrefix === "Root";
|
||||
return {
|
||||
name: isRoot ? scriptName : label,
|
||||
command,
|
||||
scriptName,
|
||||
cwd,
|
||||
label,
|
||||
} satisfies DetectedCandidate;
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectWorkspacePatterns(rootPackage: PackageJsonShape | null, pnpmWorkspaceRaw: string | null, lernaPackage: unknown): string[] {
|
||||
const patterns = new Set<string>();
|
||||
|
||||
if (pnpmWorkspaceRaw) {
|
||||
const matches = pnpmWorkspaceRaw.matchAll(/^\s*-\s*['"]?(.+?)['"]?\s*$/gm);
|
||||
for (const match of matches) {
|
||||
const pattern = match[1]?.trim();
|
||||
if (pattern) {
|
||||
patterns.add(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rootPackage?.workspaces) {
|
||||
if (Array.isArray(rootPackage.workspaces)) {
|
||||
for (const pattern of rootPackage.workspaces) {
|
||||
if (typeof pattern === "string" && pattern.trim().length > 0) {
|
||||
patterns.add(pattern.trim());
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(rootPackage.workspaces.packages)) {
|
||||
for (const pattern of rootPackage.workspaces.packages) {
|
||||
if (typeof pattern === "string" && pattern.trim().length > 0) {
|
||||
patterns.add(pattern.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray((lernaPackage as { packages?: unknown })?.packages)) {
|
||||
for (const pattern of (lernaPackage as { packages: unknown[] }).packages) {
|
||||
if (typeof pattern === "string" && pattern.trim().length > 0) {
|
||||
patterns.add(pattern.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...patterns];
|
||||
}
|
||||
|
||||
function toPackageJsonPattern(pattern: string): string {
|
||||
const normalized = pattern.replace(/\\/g, "/").replace(/\/+$|^\/+/, "");
|
||||
if (!normalized) {
|
||||
return "package.json";
|
||||
}
|
||||
if (normalized.endsWith("package.json")) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized}/package.json`;
|
||||
}
|
||||
|
||||
async function expandWorkspacePackageJsons(projectRoot: string, patterns: string[]): Promise<string[]> {
|
||||
const packageJsonPaths: string[] = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const packageJsonPattern = toPackageJsonPattern(pattern);
|
||||
try {
|
||||
for await (const matchedPath of glob(packageJsonPattern, { cwd: projectRoot })) {
|
||||
if (typeof matchedPath !== "string") {
|
||||
continue;
|
||||
}
|
||||
packageJsonPaths.push(path.resolve(projectRoot, matchedPath));
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid glob patterns and continue.
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(packageJsonPaths)];
|
||||
}
|
||||
|
||||
async function detect(projectRoot: string): Promise<DetectedCandidate[]> {
|
||||
const candidates: DetectedCandidate[] = [];
|
||||
|
||||
const rootPackagePath = path.join(projectRoot, "package.json");
|
||||
const rootPackageJson = await readJsonFile<PackageJsonShape>(rootPackagePath);
|
||||
const rootScripts = parseScripts(rootPackageJson);
|
||||
candidates.push(...buildCandidatesForScripts(rootScripts, projectRoot, "Root"));
|
||||
|
||||
const [pnpmWorkspaceRaw, lernaJson] = await Promise.all([
|
||||
readFile(path.join(projectRoot, "pnpm-workspace.yaml"), "utf-8").catch(() => null),
|
||||
readJsonFile<Record<string, unknown>>(path.join(projectRoot, "lerna.json")),
|
||||
]);
|
||||
|
||||
const workspacePatterns = collectWorkspacePatterns(rootPackageJson, pnpmWorkspaceRaw, lernaJson);
|
||||
const workspacePackageJsons = await expandWorkspacePackageJsons(projectRoot, workspacePatterns);
|
||||
|
||||
for (const packageJsonPath of workspacePackageJsons) {
|
||||
const workspacePackage = await readJsonFile<PackageJsonShape>(packageJsonPath);
|
||||
if (!workspacePackage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scripts = parseScripts(workspacePackage);
|
||||
const workspaceDir = path.dirname(packageJsonPath);
|
||||
const relativePath = path.relative(projectRoot, workspaceDir).replace(/\\/g, "/") || ".";
|
||||
candidates.push(...buildCandidatesForScripts(scripts, workspaceDir, relativePath));
|
||||
}
|
||||
|
||||
return candidates.sort((a, b) => {
|
||||
const aIndex = preferredIndex(a.scriptName);
|
||||
const bIndex = preferredIndex(b.scriptName);
|
||||
|
||||
if (aIndex !== bIndex) {
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
|
||||
if (a.label !== b.label) {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
return a.command.localeCompare(b.command);
|
||||
});
|
||||
}
|
||||
|
||||
async function getRootPackageMtime(projectRoot: string): Promise<number> {
|
||||
try {
|
||||
const stats = await stat(path.join(projectRoot, "package.json"));
|
||||
return stats.mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectDevServerCandidates(projectRoot: string): Promise<DetectedCandidate[]> {
|
||||
const resolvedRoot = path.resolve(projectRoot);
|
||||
|
||||
try {
|
||||
const mtime = await getRootPackageMtime(resolvedRoot);
|
||||
const cached = cache.get(resolvedRoot);
|
||||
if (cached && cached.mtime === mtime) {
|
||||
return cached.candidates.map((candidate) => ({ ...candidate }));
|
||||
}
|
||||
|
||||
const candidates = await detect(resolvedRoot);
|
||||
cache.set(resolvedRoot, {
|
||||
candidates: candidates.map((candidate) => ({ ...candidate })),
|
||||
mtime,
|
||||
});
|
||||
|
||||
return candidates;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateDetectionCache(projectRoot?: string): void {
|
||||
if (!projectRoot) {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
cache.delete(path.resolve(projectRoot));
|
||||
}
|
||||
520
packages/dashboard/src/dev-server-manager.ts
Normal file
520
packages/dashboard/src/dev-server-manager.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface DevServerState {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "stopped" | "starting" | "running" | "failed";
|
||||
command: string;
|
||||
scriptName: string;
|
||||
cwd: string;
|
||||
pid?: number;
|
||||
startedAt?: string;
|
||||
previewUrl?: string;
|
||||
detectedPort?: number;
|
||||
manualPreviewUrl?: string;
|
||||
logs: string[];
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
interface PersistedDevServerState {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
scriptName: string;
|
||||
cwd: string;
|
||||
pid?: number;
|
||||
startedAt?: string;
|
||||
manualPreviewUrl?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
export const MAX_LOG_LINES = 500;
|
||||
export const FALLBACK_PORTS = [3000, 4173, 5173, 6006, 8080, 4200, 4400, 8888] as const;
|
||||
|
||||
function createDefaultState(projectRoot: string): DevServerState {
|
||||
return {
|
||||
id: "default",
|
||||
name: "default",
|
||||
status: "stopped",
|
||||
command: "",
|
||||
scriptName: "",
|
||||
cwd: projectRoot,
|
||||
logs: [],
|
||||
exitCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUrl(host: string, port: number): string {
|
||||
const normalizedHost = host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" ? "localhost" : host;
|
||||
return `http://${normalizedHost}:${port}`;
|
||||
}
|
||||
|
||||
export function parseLineForUrl(line: string): { url: string; port: number } | null {
|
||||
const patterns: RegExp[] = [
|
||||
/(?:Local|local|ready on|listening on|started on|running at)\s*(?:http:\/\/)(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
|
||||
/http:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
|
||||
/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = line.match(pattern);
|
||||
if (!match) continue;
|
||||
|
||||
if (match.length === 3) {
|
||||
const [, host, rawPort] = match;
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isFinite(port)) return null;
|
||||
return { url: normalizeUrl(host, port), port };
|
||||
}
|
||||
|
||||
if (match.length === 2) {
|
||||
const [, rawPort] = match;
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isFinite(port)) return null;
|
||||
return { url: normalizeUrl("localhost", port), port };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class DevServerManager extends EventEmitter {
|
||||
private readonly stateFile: string;
|
||||
private readonly servers = new Map<string, DevServerState>();
|
||||
private readonly processes = new Map<string, ChildProcess>();
|
||||
private readonly killTimers = new Map<string, NodeJS.Timeout>();
|
||||
private portProbeTimer: NodeJS.Timeout | null = null;
|
||||
private readonly loadPromise: Promise<void>;
|
||||
|
||||
constructor(private readonly projectRoot: string) {
|
||||
super();
|
||||
this.stateFile = path.join(this.projectRoot, ".fusion", "dev-server.json");
|
||||
this.servers.set("default", createDefaultState(this.projectRoot));
|
||||
this.loadPromise = this.loadState();
|
||||
}
|
||||
|
||||
async start(command: string, scriptName: string, cwd?: string): Promise<DevServerState> {
|
||||
await this.loadPromise;
|
||||
|
||||
const current = this.getMutableState();
|
||||
if (current.status !== "stopped") {
|
||||
throw new Error(`Dev server is already ${current.status}`);
|
||||
}
|
||||
|
||||
const resolvedCwd = cwd ? path.resolve(cwd) : this.projectRoot;
|
||||
const startedAt = new Date().toISOString();
|
||||
const nextState: DevServerState = {
|
||||
...current,
|
||||
id: "default",
|
||||
name: "default",
|
||||
status: "starting",
|
||||
command,
|
||||
scriptName,
|
||||
cwd: resolvedCwd,
|
||||
pid: undefined,
|
||||
startedAt,
|
||||
previewUrl: current.manualPreviewUrl,
|
||||
detectedPort: undefined,
|
||||
logs: [],
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
this.servers.set("default", nextState);
|
||||
this.emit("status", this.cloneState(nextState));
|
||||
|
||||
const child = spawn(command, [], {
|
||||
cwd: resolvedCwd,
|
||||
shell: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
FORCE_COLOR: "1",
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
});
|
||||
|
||||
if (child.pid) {
|
||||
nextState.pid = child.pid;
|
||||
}
|
||||
|
||||
this.processes.set("default", child);
|
||||
|
||||
const onOutput = (chunk: Buffer, markRunning: boolean): void => {
|
||||
const lines = chunk
|
||||
.toString("utf-8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
const state = this.getMutableState();
|
||||
this.pushLogLine(state, line);
|
||||
|
||||
if (markRunning && state.status === "starting") {
|
||||
state.status = "running";
|
||||
this.emit("status", this.cloneState(state));
|
||||
|
||||
if (!state.previewUrl) {
|
||||
this.scheduleFallbackPortProbe();
|
||||
}
|
||||
}
|
||||
|
||||
this.tryParseAndApplyUrl(line);
|
||||
this.emit("log", { serverId: "default", line });
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
onOutput(data, true);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
onOutput(data, false);
|
||||
});
|
||||
|
||||
child.on("exit", (code: number | null) => {
|
||||
const state = this.getMutableState();
|
||||
state.status = code === 0 ? "stopped" : "failed";
|
||||
state.exitCode = code;
|
||||
state.pid = undefined;
|
||||
|
||||
this.processes.delete("default");
|
||||
this.clearKillTimer("default");
|
||||
this.clearPortProbeTimer();
|
||||
|
||||
if (state.manualPreviewUrl) {
|
||||
state.previewUrl = state.manualPreviewUrl;
|
||||
}
|
||||
|
||||
this.emit("status", this.cloneState(state));
|
||||
this.persistState();
|
||||
});
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
const state = this.getMutableState();
|
||||
state.status = "failed";
|
||||
state.exitCode = 1;
|
||||
state.pid = undefined;
|
||||
this.pushLogLine(state, `[dev-server] ${err.message}`);
|
||||
|
||||
this.processes.delete("default");
|
||||
this.clearKillTimer("default");
|
||||
this.clearPortProbeTimer();
|
||||
|
||||
this.emit("status", this.cloneState(state));
|
||||
this.persistState();
|
||||
});
|
||||
|
||||
this.persistState();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async stop(): Promise<DevServerState> {
|
||||
await this.loadPromise;
|
||||
|
||||
const state = this.getMutableState();
|
||||
const child = this.processes.get("default");
|
||||
if (!child || (state.status !== "running" && state.status !== "starting")) {
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
state.status = "stopped";
|
||||
this.emit("status", this.cloneState(state));
|
||||
|
||||
child.kill("SIGTERM");
|
||||
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!this.processes.has("default")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processToKill = this.processes.get("default");
|
||||
processToKill?.kill("SIGKILL");
|
||||
}, 5_000);
|
||||
this.killTimers.set("default", killTimer);
|
||||
|
||||
this.clearPortProbeTimer();
|
||||
this.persistState();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async restart(): Promise<DevServerState> {
|
||||
await this.loadPromise;
|
||||
|
||||
const state = this.getMutableState();
|
||||
const command = state.command;
|
||||
const scriptName = state.scriptName;
|
||||
const cwd = state.cwd;
|
||||
|
||||
if (!command || !scriptName) {
|
||||
throw new Error("Cannot restart dev server before it has been started once");
|
||||
}
|
||||
|
||||
if (state.status !== "stopped" && state.status !== "failed") {
|
||||
await this.stop();
|
||||
await this.waitForProcessExit("default", 5_500);
|
||||
}
|
||||
|
||||
return this.start(command, scriptName, cwd);
|
||||
}
|
||||
|
||||
getState(): DevServerState {
|
||||
const state = this.servers.get("default");
|
||||
if (!state) {
|
||||
return createDefaultState(this.projectRoot);
|
||||
}
|
||||
return this.cloneState(state);
|
||||
}
|
||||
|
||||
getAllStates(): DevServerState[] {
|
||||
return Array.from(this.servers.values()).map((state) => this.cloneState(state));
|
||||
}
|
||||
|
||||
getLogs(tail?: number): string[] {
|
||||
const logs = this.getMutableState().logs;
|
||||
if (tail === undefined || tail <= 0 || tail >= logs.length) {
|
||||
return [...logs];
|
||||
}
|
||||
return logs.slice(-tail);
|
||||
}
|
||||
|
||||
setManualPreviewUrl(url: string | null): DevServerState {
|
||||
const state = this.getMutableState();
|
||||
state.manualPreviewUrl = url ?? undefined;
|
||||
|
||||
if (url) {
|
||||
state.previewUrl = url;
|
||||
} else if (state.detectedPort) {
|
||||
state.previewUrl = normalizeUrl("localhost", state.detectedPort);
|
||||
} else {
|
||||
state.previewUrl = undefined;
|
||||
}
|
||||
|
||||
this.emit("status", this.cloneState(state));
|
||||
this.persistState();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const [serverId, child] of this.processes.entries()) {
|
||||
child.kill("SIGTERM");
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!this.processes.has(serverId)) {
|
||||
return;
|
||||
}
|
||||
this.processes.get(serverId)?.kill("SIGKILL");
|
||||
}, 5_000);
|
||||
this.killTimers.set(serverId, killTimer);
|
||||
}
|
||||
|
||||
for (const timer of this.killTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.killTimers.clear();
|
||||
|
||||
this.clearPortProbeTimer();
|
||||
this.removeAllListeners();
|
||||
this.processes.clear();
|
||||
this.servers.clear();
|
||||
}
|
||||
|
||||
private getMutableState(): DevServerState {
|
||||
const state = this.servers.get("default");
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const fallback = createDefaultState(this.projectRoot);
|
||||
this.servers.set("default", fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private cloneState(state: DevServerState): DevServerState {
|
||||
return {
|
||||
...state,
|
||||
logs: [...state.logs],
|
||||
};
|
||||
}
|
||||
|
||||
private tryParseAndApplyUrl(line: string): void {
|
||||
const parsed = parseLineForUrl(line);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.getMutableState();
|
||||
state.detectedPort = parsed.port;
|
||||
if (!state.manualPreviewUrl) {
|
||||
state.previewUrl = parsed.url;
|
||||
}
|
||||
|
||||
this.emit("url-detected", { serverId: "default", url: parsed.url, port: parsed.port });
|
||||
this.clearPortProbeTimer();
|
||||
this.emit("status", this.cloneState(state));
|
||||
this.persistState();
|
||||
}
|
||||
|
||||
private scheduleFallbackPortProbe(): void {
|
||||
this.clearPortProbeTimer();
|
||||
this.portProbeTimer = setTimeout(() => {
|
||||
this.probeFallbackPorts();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
private probeFallbackPorts(): void {
|
||||
void (async () => {
|
||||
for (const port of FALLBACK_PORTS) {
|
||||
const available = await this.testPort(port);
|
||||
if (!available) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = this.getMutableState();
|
||||
state.detectedPort = port;
|
||||
if (!state.manualPreviewUrl) {
|
||||
state.previewUrl = normalizeUrl("localhost", port);
|
||||
}
|
||||
|
||||
this.emit("url-detected", { serverId: "default", url: normalizeUrl("localhost", port), port });
|
||||
this.emit("status", this.cloneState(state));
|
||||
this.persistState();
|
||||
return;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async testPort(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ port, host: "localhost" });
|
||||
|
||||
const complete = (result: boolean): void => {
|
||||
socket.removeAllListeners();
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
socket.setTimeout(500);
|
||||
socket.once("connect", () => complete(true));
|
||||
socket.once("timeout", () => complete(false));
|
||||
socket.once("error", () => complete(false));
|
||||
});
|
||||
}
|
||||
|
||||
private clearPortProbeTimer(): void {
|
||||
if (!this.portProbeTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.portProbeTimer);
|
||||
this.portProbeTimer = null;
|
||||
}
|
||||
|
||||
private clearKillTimer(serverId: string): void {
|
||||
const timer = this.killTimers.get(serverId);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
this.killTimers.delete(serverId);
|
||||
}
|
||||
|
||||
private pushLogLine(state: DevServerState, line: string): void {
|
||||
state.logs.push(line);
|
||||
if (state.logs.length > MAX_LOG_LINES) {
|
||||
state.logs.splice(0, state.logs.length - MAX_LOG_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForProcessExit(serverId: string, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (this.processes.has(serverId) && Date.now() - start < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
private persistState(): void {
|
||||
const state = this.getMutableState();
|
||||
const payload: PersistedDevServerState = {
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
command: state.command,
|
||||
scriptName: state.scriptName,
|
||||
cwd: state.cwd,
|
||||
manualPreviewUrl: state.manualPreviewUrl,
|
||||
exitCode: state.exitCode,
|
||||
pid: state.pid,
|
||||
startedAt: state.startedAt,
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await mkdir(path.dirname(this.stateFile), { recursive: true });
|
||||
await writeFile(this.stateFile, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
||||
} catch (err) {
|
||||
console.error("[dev-server] Failed to persist state:", err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async loadState(): Promise<void> {
|
||||
try {
|
||||
const raw = await readFile(this.stateFile, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedDevServerState>;
|
||||
const state = this.getMutableState();
|
||||
|
||||
state.command = typeof parsed.command === "string" ? parsed.command : "";
|
||||
state.scriptName = typeof parsed.scriptName === "string" ? parsed.scriptName : "";
|
||||
state.cwd = typeof parsed.cwd === "string" ? parsed.cwd : this.projectRoot;
|
||||
state.manualPreviewUrl = typeof parsed.manualPreviewUrl === "string" ? parsed.manualPreviewUrl : undefined;
|
||||
state.previewUrl = state.manualPreviewUrl;
|
||||
state.startedAt = typeof parsed.startedAt === "string" ? parsed.startedAt : undefined;
|
||||
state.exitCode = parsed.exitCode ?? null;
|
||||
|
||||
const maybePid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
||||
if (maybePid === undefined) {
|
||||
state.status = "stopped";
|
||||
state.pid = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(maybePid, 0);
|
||||
state.status = "running";
|
||||
state.pid = maybePid;
|
||||
} catch {
|
||||
state.status = "stopped";
|
||||
state.pid = undefined;
|
||||
}
|
||||
} catch {
|
||||
this.servers.set("default", createDefaultState(this.projectRoot));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const managers: Map<string, DevServerManager> = new Map();
|
||||
|
||||
export function getDevServerManager(projectRoot: string): DevServerManager {
|
||||
const resolvedRoot = path.resolve(projectRoot);
|
||||
const existing = managers.get(resolvedRoot);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const manager = new DevServerManager(resolvedRoot);
|
||||
managers.set(resolvedRoot, manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
export function destroyAllDevServerManagers(): void {
|
||||
for (const manager of managers.values()) {
|
||||
manager.destroy();
|
||||
}
|
||||
managers.clear();
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { GitHubClient, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
import { getTerminalService } from "./terminal-service.js";
|
||||
import { getDevServerManager } from "./dev-server-manager.js";
|
||||
import { detectDevServerCandidates, invalidateDetectionCache } from "./dev-server-detect.js";
|
||||
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, searchWorkspaceFiles, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, listProjectMarkdownFiles, scanMarkdownFiles, FileServiceError, type MarkdownFileListResponse } from "./file-service.js";
|
||||
import { clearUsageCache, fetchAllProviderUsage } from "./usage.js";
|
||||
import {
|
||||
@@ -7596,6 +7598,129 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/* === Dev Server Routes === */
|
||||
|
||||
router.get("/dev-server/candidates", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const candidates = await detectDevServerCandidates(scopedStore.getRootDir());
|
||||
res.json(candidates);
|
||||
} catch (err: unknown) {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/dev-server/status", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const manager = getDevServerManager(scopedStore.getRootDir());
|
||||
res.json(manager.getState());
|
||||
} catch (err: unknown) {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dev-server/start", async (req, res) => {
|
||||
try {
|
||||
const { command, scriptName, cwd } = req.body ?? {};
|
||||
|
||||
if (typeof command !== "string" || command.trim().length === 0) {
|
||||
throw badRequest("command is required");
|
||||
}
|
||||
if (typeof scriptName !== "string" || scriptName.trim().length === 0) {
|
||||
throw badRequest("scriptName is required");
|
||||
}
|
||||
if (cwd !== undefined && typeof cwd !== "string") {
|
||||
throw badRequest("cwd must be a string when provided");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const manager = getDevServerManager(rootDir);
|
||||
|
||||
await manager.start(command, scriptName, cwd);
|
||||
invalidateDetectionCache(rootDir);
|
||||
res.json(manager.getState());
|
||||
} catch (err: unknown) {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dev-server/stop", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const manager = getDevServerManager(scopedStore.getRootDir());
|
||||
await manager.stop();
|
||||
res.json(manager.getState());
|
||||
} catch (err: unknown) {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dev-server/restart", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const manager = getDevServerManager(scopedStore.getRootDir());
|
||||
await manager.restart();
|
||||
res.json(manager.getState());
|
||||
} catch (err: unknown) {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dev-server/preview-url", async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body ?? {};
|
||||
if (url !== null && typeof url !== "string") {
|
||||
throw badRequest("url must be a string or null");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const manager = getDevServerManager(scopedStore.getRootDir());
|
||||
manager.setManualPreviewUrl(url);
|
||||
res.json(manager.getState());
|
||||
} catch (err: unknown) {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/dev-server/logs/stream", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const manager = getDevServerManager(scopedStore.getRootDir());
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({})}\n\n`);
|
||||
|
||||
const onLog = (event: { serverId: string; line: string }) => {
|
||||
res.write(`event: dev-server:log\ndata: ${JSON.stringify({ line: event.line })}\n\n`);
|
||||
};
|
||||
|
||||
const onStatus = (state: import("./dev-server-manager.js").DevServerState) => {
|
||||
res.write(`event: dev-server:status\ndata: ${JSON.stringify(state)}\n\n`);
|
||||
};
|
||||
|
||||
manager.on("log", onLog);
|
||||
manager.on("status", onStatus);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
manager.off("log", onLog);
|
||||
manager.off("status", onStatus);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── PTY Terminal Routes (WebSocket-based) ────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
|
||||
import { ApiError, sendErrorResponse } from "./api-error.js";
|
||||
import { getOrCreateProjectStore, evictAllProjectStores, setOnProjectFirstCreated } from "./project-store-resolver.js";
|
||||
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
||||
import { destroyAllDevServerManagers, getDevServerManager } from "./dev-server-manager.js";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
|
||||
@@ -392,6 +393,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
// Initialize terminal service with project root
|
||||
getTerminalService(store.getRootDir());
|
||||
|
||||
// Initialize dev server manager for this project
|
||||
getDevServerManager(store.getRootDir());
|
||||
|
||||
const isHeadless = options?.headless === true;
|
||||
|
||||
// Serve built React app
|
||||
@@ -823,6 +827,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
server.once("close", () => {
|
||||
clearAiSessionCleanupInterval();
|
||||
aiSessionStore.stopScheduledCleanup();
|
||||
destroyAllDevServerManagers();
|
||||
});
|
||||
|
||||
if (!dashboardApp.__fnWebSocketsAttached) {
|
||||
|
||||
Reference in New Issue
Block a user