Files
fusion/packages/dashboard/src/dev-server-manager.ts
Fusion 99cafa3a44 feat(FN-2170): add dev-server preview detection and log viewer flow
- Add dev-server port detection utilities and wire detected preview state into process/manager lifecycle
- Normalize dev-server status API responses with preview URL, detected port, and manual override compatibility
- Introduce useDevServerLogs and DevServerLogViewer, and integrate them into the DevServer dashboard view
- Expand dashboard test coverage for detection edges, process/routes behavior, log history handling, and CSS regressions
2026-04-20 15:37:32 -07:00

404 lines
11 KiB
TypeScript

import { EventEmitter } from "node:events";
import { resolve } from "node:path";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import { DevServerProcessManager, type DevServerProcessManagerOptions } from "./dev-server-process.js";
import {
loadDevServerStore,
resetDevServerStore,
type DevServerState,
type DevServerStatus,
type DevServerStore,
} from "./dev-server-store.js";
const DEFAULT_SERVER_KEY = "default";
const DEFAULT_LOG_LIMIT = 200;
const DEFAULT_BUFFER_CAPACITY = 400;
// Reserved dashboard port 4040 must never be suggested as a fallback dev-server port.
export const FALLBACK_PORTS = [5173, 3000, 4173, 6006, 8080, 4200, 4400, 8888, 4321, 4000] as const;
type LegacyDevServerStatus = DevServerStatus | "idle";
type DevServerLogSource = "stdout" | "stderr" | "system";
export interface DevServerPersistedLogEntry {
serverKey: string;
source: DevServerLogSource;
message: string;
timestamp: string;
}
export interface DevServerPersistedState {
serverKey: string;
status: LegacyDevServerStatus;
command: string | null;
scriptName: string | null;
cwd: string | null;
pid: number | null;
startedAt: string | null;
updatedAt: string;
previewUrl: string | null;
previewProtocol: string | null;
previewHost: string | null;
previewPort: number | null;
previewPath: string | null;
exitCode: number | null;
exitSignal: string | null;
exitedAt: string | null;
failureReason: string | null;
}
export interface DevServerStartOptions {
command: string;
cwd?: string;
scriptName?: string | null;
}
export interface DevServerSnapshot {
state: DevServerPersistedState;
logs: DevServerPersistedLogEntry[];
}
export interface DevServerUrlDetectedEvent {
url: string;
port: number;
source: string;
detectedAt: string;
}
export interface DevServerManagerEvent {
type: "state" | "log";
data: DevServerPersistedState | DevServerPersistedLogEntry;
}
type DevServerSubscriber = (event: DevServerManagerEvent, eventId: number) => void;
export interface DevServerManagerOptions {
logLimit?: number;
processOptions?: DevServerProcessManagerOptions;
}
export class DevServerManager extends EventEmitter {
private readonly subscribers = new Set<DevServerSubscriber>();
private readonly eventBuffer = new SessionEventBuffer(DEFAULT_BUFFER_CAPACITY);
private readonly logLimit: number;
private readonly processManager: DevServerProcessManager;
private state: DevServerPersistedState = createDefaultState();
private logs: DevServerPersistedLogEntry[] = [];
private initialized = false;
constructor(
private readonly rootDir: string,
private readonly store: DevServerStore,
options?: DevServerManagerOptions,
) {
super();
this.logLimit = options?.logLimit ?? DEFAULT_LOG_LIMIT;
this.processManager = new DevServerProcessManager(store, options?.processOptions);
this.bindProcessEvents();
}
private bindProcessEvents(): void {
this.processManager.on("started", (state: DevServerState) => {
this.applyDevServerState(state);
});
this.processManager.on("output", (payload: { line: string; stream: "stdout" | "stderr"; timestamp: string }) => {
this.appendLog({
serverKey: this.state.serverKey,
source: payload.stream,
message: payload.line,
timestamp: payload.timestamp,
});
});
this.processManager.on("stopped", (state: DevServerState) => {
this.applyDevServerState(state);
});
this.processManager.on("failed", (payload: { error: string }) => {
this.state = {
...this.state,
status: "failed",
failureReason: payload.error,
updatedAt: new Date().toISOString(),
};
this.broadcast({ type: "state", data: this.state });
this.appendLog({
serverKey: this.state.serverKey,
source: "system",
message: payload.error,
timestamp: new Date().toISOString(),
});
});
this.processManager.on("url-detected", (payload: DevServerUrlDetectedEvent) => {
this.applyDevServerState(this.store.getState());
this.emit("url-detected", payload);
});
}
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
await this.store.load();
this.applyDevServerState(this.store.getState());
if (
(this.state.status === "running" || this.state.status === "starting")
&& this.state.pid
&& !isProcessAlive(this.state.pid)
) {
await this.store.updateState({
status: "stopped",
pid: undefined,
exitCode: 1,
stoppedAt: new Date().toISOString(),
});
await this.store.appendLog("Recovered stale persisted PID and marked server as stopped");
this.applyDevServerState(this.store.getState());
}
this.initialized = true;
}
getState(): DevServerPersistedState {
return { ...this.state };
}
hasPendingFallbackProbeTimer(): boolean {
return this.processManager.hasPendingProbeTimer();
}
getRecentLogs(limit = this.logLimit): DevServerPersistedLogEntry[] {
if (!Number.isFinite(limit) || limit <= 0) {
return [];
}
return this.logs.slice(-Math.floor(limit)).map((entry) => ({ ...entry }));
}
getSnapshot(limit = this.logLimit): DevServerSnapshot {
return {
state: this.getState(),
logs: this.getRecentLogs(limit),
};
}
getBufferedEvents(sinceId: number): SessionBufferedEvent[] {
return this.eventBuffer.getEventsSince(sinceId);
}
subscribe(callback: DevServerSubscriber): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async start(options: DevServerStartOptions): Promise<DevServerPersistedState> {
await this.ensureInitialized();
const command = options.command.trim();
if (!command) {
throw new Error("command is required");
}
const cwd = options.cwd ? resolve(options.cwd) : this.rootDir;
const state = await this.processManager.start(command, cwd, {
scriptId: options.scriptName ?? undefined,
});
this.applyDevServerState(state);
return this.getState();
}
async stop(_reason = "Stopped by user"): Promise<DevServerPersistedState> {
await this.ensureInitialized();
const state = await this.processManager.stop();
this.applyDevServerState(state);
return this.getState();
}
async restart(options?: DevServerStartOptions): Promise<DevServerPersistedState> {
await this.ensureInitialized();
if (options?.command || options?.cwd || options?.scriptName !== undefined) {
const current = this.store.getState();
await this.store.updateState({
command: options.command?.trim() || current.command,
cwd: options.cwd ? resolve(options.cwd) : current.cwd,
scriptId: options.scriptName ?? current.scriptId,
});
}
const state = await this.processManager.restart();
this.applyDevServerState(state);
return this.getState();
}
async shutdown(): Promise<void> {
if (this.processManager.isRunning()) {
await this.processManager.stop();
}
this.processManager.cleanup();
}
resetForTests(): void {
this.processManager.cleanup();
this.subscribers.clear();
this.eventBuffer.clear();
this.logs = [];
this.state = createDefaultState();
this.initialized = false;
}
private applyDevServerState(state: DevServerState): void {
this.state = toPersistedState(state);
this.logs = state.logHistory.slice(-this.logLimit).map((message) => ({
serverKey: this.state.serverKey,
source: "system" as const,
message,
timestamp: new Date().toISOString(),
}));
this.broadcast({ type: "state", data: this.state });
}
private appendLog(entry: DevServerPersistedLogEntry): void {
this.logs.push(entry);
if (this.logs.length > this.logLimit) {
this.logs.splice(0, this.logs.length - this.logLimit);
}
this.broadcast({ type: "log", data: entry });
}
private broadcast(event: DevServerManagerEvent): number {
const payload = JSON.stringify(event.data);
const eventId = this.eventBuffer.push(event.type, payload);
for (const subscriber of this.subscribers) {
try {
subscriber(event, eventId);
} catch {
// Ignore per-subscriber failures.
}
}
this.emit("event", event, eventId);
return eventId;
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
}
function createDefaultState(): DevServerPersistedState {
return {
serverKey: DEFAULT_SERVER_KEY,
status: "idle",
command: null,
scriptName: null,
cwd: null,
pid: null,
startedAt: null,
updatedAt: new Date().toISOString(),
previewUrl: null,
previewProtocol: null,
previewHost: null,
previewPort: null,
previewPath: null,
exitCode: null,
exitSignal: null,
exitedAt: null,
failureReason: null,
};
}
function toPersistedState(state: DevServerState): DevServerPersistedState {
const previewUrl = state.manualUrl ?? state.detectedUrl ?? null;
let previewProtocol: string | null = null;
let previewHost: string | null = null;
let previewPort: number | null = state.detectedPort ?? null;
let previewPath: string | null = null;
if (previewUrl) {
try {
const parsed = new URL(previewUrl);
previewProtocol = parsed.protocol.replace(/:$/, "");
previewHost = parsed.hostname;
previewPort = parsed.port.length > 0 ? Number.parseInt(parsed.port, 10) : previewPort;
previewPath = parsed.pathname || "/";
} catch {
// Ignore invalid URLs.
}
}
return {
serverKey: state.name || DEFAULT_SERVER_KEY,
status: state.status,
command: state.command || null,
scriptName: state.scriptId ?? null,
cwd: state.cwd || null,
pid: state.pid ?? null,
startedAt: state.startedAt ?? null,
updatedAt: new Date().toISOString(),
previewUrl,
previewProtocol,
previewHost,
previewPort,
previewPath,
exitCode: state.exitCode ?? null,
exitSignal: null,
exitedAt: state.stoppedAt ?? null,
failureReason: state.status === "failed" ? "Process failed" : null,
};
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
const managerInstances = new Map<string, DevServerManager>();
export async function loadDevServerManager(rootDir: string): Promise<DevServerManager> {
const key = resolve(rootDir);
let manager = managerInstances.get(key);
if (!manager) {
const store = await loadDevServerStore(rootDir);
manager = new DevServerManager(key, store);
managerInstances.set(key, manager);
await manager.initialize();
}
return manager;
}
export async function shutdownAllDevServerManagers(): Promise<void> {
for (const manager of managerInstances.values()) {
await manager.shutdown();
}
}
export function resetDevServerManager(): void {
for (const manager of managerInstances.values()) {
manager.resetForTests();
}
managerInstances.clear();
resetDevServerStore();
}