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
This commit is contained in:
Fusion
2026-04-20 15:37:32 -07:00
committed by gsxdsm
parent 6522369168
commit c8203210c2
17 changed files with 3585 additions and 1111 deletions

View File

@@ -0,0 +1,173 @@
// @vitest-environment node
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
DevServerManager,
resetDevServerManager,
type DevServerUrlDetectedEvent,
type DevServerManagerOptions,
} from "../dev-server-manager.js";
import { DevServerStore } from "../dev-server-store.js";
async function createManager(
rootDir: string,
options?: DevServerManagerOptions,
): Promise<{ manager: DevServerManager; store: DevServerStore }> {
const store = new DevServerStore(rootDir);
await store.load();
const manager = new DevServerManager(rootDir, store, {
logLimit: 50,
...options,
});
await manager.initialize();
return { manager, store };
}
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("Timed out waiting for condition");
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
describe("DevServerManager", () => {
const managers: DevServerManager[] = [];
afterEach(async () => {
for (const manager of managers.splice(0)) {
try {
await manager.shutdown();
} catch {
// ignore cleanup failures
}
}
resetDevServerManager();
});
it("emits url-detected with source and detectedAt payload", async () => {
const root = await mkdtemp(join(tmpdir(), "dev-server-manager-"));
const { manager } = await createManager(root);
managers.push(manager);
const detectedPromise = new Promise<DevServerUrlDetectedEvent>((resolve) => {
manager.once("url-detected", (payload: DevServerUrlDetectedEvent) => resolve(payload));
});
await manager.start({
command: "node -e \"console.log('preview at http://localhost:5173/'); setInterval(() => {}, 1000)\"",
scriptName: "dev",
});
const detected = await detectedPromise;
expect(detected).toMatchObject({
url: "http://localhost:5173",
port: 5173,
source: "generic-url",
});
expect(Number.isNaN(Date.parse(detected.detectedAt))).toBe(false);
});
it("keeps manual preview URL effective while detected fields keep updating", async () => {
const root = await mkdtemp(join(tmpdir(), "dev-server-manager-"));
const { manager, store } = await createManager(root);
managers.push(manager);
await store.updateState({ manualUrl: "https://localhost:9999" });
await manager.start({
command: "node -e \"console.log('ready at http://localhost:4321/'); setInterval(() => {}, 1000)\"",
scriptName: "dev",
});
await waitFor(() => (store.getState().detectedPort ?? 0) === 4321);
const persisted = store.getState();
const derived = manager.getState();
expect(persisted.detectedUrl).toBe("http://localhost:4321");
expect(persisted.detectedPort).toBe(4321);
expect(persisted.manualUrl).toBe("https://localhost:9999");
expect(derived.previewUrl).toBe("https://localhost:9999");
expect(derived.previewPort).toBe(9999);
});
it("schedules and clears fallback probe timers through start/stop", async () => {
const root = await mkdtemp(join(tmpdir(), "dev-server-manager-"));
const { manager } = await createManager(root, {
processOptions: { probeDelayMs: 2_000, probeTimeoutMs: 5 },
});
managers.push(manager);
await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"",
scriptName: "dev",
});
expect(manager.hasPendingFallbackProbeTimer()).toBe(true);
await manager.stop();
expect(manager.hasPendingFallbackProbeTimer()).toBe(false);
});
it("runs fallback probe after grace period when logs do not announce a URL", async () => {
const root = await mkdtemp(join(tmpdir(), "dev-server-manager-"));
const { manager } = await createManager(root, {
processOptions: { probeDelayMs: 25, probeTimeoutMs: 5 },
});
managers.push(manager);
await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"",
scriptName: "dev",
});
expect(manager.hasPendingFallbackProbeTimer()).toBe(true);
await waitFor(() => manager.hasPendingFallbackProbeTimer() === false, 3_000);
});
it("restart keeps fallback probing active with a fresh timer", async () => {
const root = await mkdtemp(join(tmpdir(), "dev-server-manager-"));
const { manager } = await createManager(root, {
processOptions: { probeDelayMs: 2_000, probeTimeoutMs: 5 },
});
managers.push(manager);
await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"",
scriptName: "dev",
});
expect(manager.hasPendingFallbackProbeTimer()).toBe(true);
await manager.restart();
expect(manager.hasPendingFallbackProbeTimer()).toBe(true);
});
it("shutdown clears fallback probe timers", async () => {
const root = await mkdtemp(join(tmpdir(), "dev-server-manager-"));
const { manager } = await createManager(root, {
processOptions: { probeDelayMs: 2_000, probeTimeoutMs: 5 },
});
managers.push(manager);
await manager.start({
command: "node -e \"setInterval(() => {}, 1000)\"",
scriptName: "dev",
});
expect(manager.hasPendingFallbackProbeTimer()).toBe(true);
await manager.shutdown();
expect(manager.hasPendingFallbackProbeTimer()).toBe(false);
});
});

View File

@@ -0,0 +1,241 @@
// @vitest-environment node
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
detectPortFromLogLine,
detectPortFromLogs,
} from "../dev-server-port-detect.js";
describe("dev-server-port-detect", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("node:net");
});
it("detects and normalizes Vite local URL lines", () => {
const result = detectPortFromLogLine(" ➜ Local: http://localhost:5173/ ");
expect(result).toEqual({
url: "http://localhost:5173",
port: 5173,
source: "vite",
});
});
it("detects Next.js startup URL lines", () => {
const result = detectPortFromLogLine(
"ready - started server on 0.0.0.0:3000, url: http://localhost:3000",
);
expect(result).toEqual({
url: "http://localhost:3000",
port: 3000,
source: "nextjs",
});
});
it("detects Next.js local URL lines using the - Local pattern", () => {
const result = detectPortFromLogLine(" - Local: http://localhost:3000");
expect(result).toEqual({
url: "http://localhost:3000",
port: 3000,
source: "nextjs",
});
});
it("detects Vite URL lines that include the framework name", () => {
const result = detectPortFromLogLine("vite v6 started at http://localhost:5173/");
expect(result).toEqual({
url: "http://localhost:5173",
port: 5173,
source: "vite",
});
});
it("detects generic keyword + port log lines", () => {
const result = detectPortFromLogLine("Server listening on port 4173");
expect(result).toEqual({
url: "http://localhost:4173",
port: 4173,
source: "generic-port",
});
});
it("detects Storybook local URL lines", () => {
const result = detectPortFromLogLine("=> Local: http://localhost:6006/");
expect(result).toEqual({
url: "http://localhost:6006",
port: 6006,
source: "storybook",
});
});
it("detects Angular host/port lines without explicit URL", () => {
const result = detectPortFromLogLine(
"** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **",
);
expect(result).toEqual({
url: "http://localhost:4200",
port: 4200,
source: "angular",
});
});
it("falls back to generic localhost URL detection", () => {
const result = detectPortFromLogLine("Preview available at localhost:4321/");
expect(result).toEqual({
url: "http://localhost:4321",
port: 4321,
source: "generic-url",
});
});
it("detects ANSI-colored 127.0.0.1 lines", () => {
const result = detectPortFromLogLine("\u001b[32mready\u001b[39m at http://127.0.0.1:4400/");
expect(result).toEqual({
url: "http://127.0.0.1:4400",
port: 4400,
source: "generic-url",
});
});
it("detectPortFromLogs searches from latest line to oldest", () => {
const result = detectPortFromLogs([
"old: http://localhost:3000/",
"new: http://localhost:5173/",
]);
expect(result).toEqual({
url: "http://localhost:5173",
port: 5173,
source: "generic-url",
});
});
it("returns null for malformed or non-matching lines", () => {
expect(detectPortFromLogLine("build complete")).toBeNull();
expect(detectPortFromLogLine("")).toBeNull();
expect(detectPortFromLogLine("http://example.com:5173")).toBeNull();
expect(detectPortFromLogLine("port: 99999")).toBeNull();
expect(detectPortFromLogLine("http://localhost:abc")).toBeNull();
});
it("excludes reserved dashboard port 4040", () => {
expect(detectPortFromLogLine("http://localhost:4040/")).toBeNull();
expect(detectPortFromLogLine("Listening on port 4040")).toBeNull();
});
it("probeFallbackPorts returns first open port in fallback order", async () => {
const sockets: Array<ReturnType<typeof createMockSocket>> = [];
const createConnection = vi.fn(({ port }: { host: string; port: number }) => {
const socket = createMockSocket();
sockets.push(socket);
queueMicrotask(() => {
if (port === 4200) {
socket.emit("connect");
} else {
socket.emit("error", new Error("ECONNREFUSED"));
}
});
return socket;
});
vi.doMock("node:net", () => ({ createConnection }));
const { probeFallbackPorts } = await import("../dev-server-port-detect.js");
const result = await probeFallbackPorts("localhost", 150);
expect(result).toEqual({
url: "http://localhost:4200",
port: 4200,
source: "fallback-probe",
});
expect(createConnection).toHaveBeenCalled();
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ host: "localhost", port: 5173 }),
);
expect(sockets[0]?.setTimeout).toHaveBeenCalledWith(150);
});
it("probeFallbackPorts normalizes host input before probing", async () => {
const createConnection = vi.fn(({ port }: { host: string; port: number }) => {
const socket = createMockSocket();
queueMicrotask(() => {
if (port === 5173) {
socket.emit("connect");
} else {
socket.emit("error", new Error("ECONNREFUSED"));
}
});
return socket;
});
vi.doMock("node:net", () => ({ createConnection }));
const { probeFallbackPorts } = await import("../dev-server-port-detect.js");
const result = await probeFallbackPorts(" https://localhost/ ", 80);
expect(result).toEqual({
url: "http://localhost:5173",
port: 5173,
source: "fallback-probe",
});
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ host: "localhost", port: 5173 }),
);
});
it("probeFallbackPorts returns null when no fallback ports are open", async () => {
const sockets: Array<ReturnType<typeof createMockSocket>> = [];
const createConnection = vi.fn(() => {
const socket = createMockSocket();
sockets.push(socket);
queueMicrotask(() => {
socket.emit("error", new Error("ECONNREFUSED"));
});
return socket;
});
vi.doMock("node:net", () => ({ createConnection }));
const { probeFallbackPorts, FALLBACK_PREVIEW_PORTS } = await import("../dev-server-port-detect.js");
const result = await probeFallbackPorts(undefined, 50);
expect(result).toBeNull();
expect(createConnection).toHaveBeenCalledTimes(FALLBACK_PREVIEW_PORTS.length);
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ host: "127.0.0.1", port: 5173 }),
);
const probedPorts = createConnection.mock.calls.map(([args]) => (args as { port: number }).port);
expect(probedPorts).not.toContain(4040);
expect(sockets[0]?.setTimeout).toHaveBeenCalledWith(50);
});
});
function createMockSocket(): EventEmitter & {
setTimeout: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
} {
const emitter = new EventEmitter() as EventEmitter & {
setTimeout: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
};
emitter.setTimeout = vi.fn(() => emitter);
emitter.end = vi.fn();
emitter.destroy = vi.fn();
return emitter;
}

View File

@@ -40,7 +40,7 @@ describe("DevServerProcessManager", () => {
resetDevServerStore();
});
async function createManager(options?: { stopTimeoutMs?: number; probeDelayMs?: number }) {
async function createManager(options?: { stopTimeoutMs?: number; probeDelayMs?: number; probeTimeoutMs?: number }) {
const root = mkdtempSync(join(os.tmpdir(), "fn-dev-process-"));
tempDirs.push(root);
const store = await loadDevServerStore(root);
@@ -170,6 +170,60 @@ describe("DevServerProcessManager", () => {
expect(store.getState().detectedPort).toBe(5173);
});
it("schedules fallback probing after startup when no URL is announced", async () => {
const { root, manager } = await createManager({ probeDelayMs: 25, probeTimeoutMs: 5 });
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
expect(manager.hasPendingProbeTimer()).toBe(true);
await waitFor(() => manager.hasPendingProbeTimer() === false, 3_000);
});
it("clears fallback probe timer when URL is detected from logs", async () => {
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
await manager.start(
"node -e \"console.log('ready at http://localhost:4321'); setInterval(() => {}, 1000)\"",
root,
);
await waitFor(() => store.getState().detectedPort === 4321);
expect(manager.hasPendingProbeTimer()).toBe(false);
});
it("clears fallback probe timer on stop", async () => {
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
expect(manager.hasPendingProbeTimer()).toBe(true);
await manager.stop();
expect(manager.hasPendingProbeTimer()).toBe(false);
});
it("clears fallback probe timer when process exits naturally", async () => {
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
await manager.start("node -e \"setTimeout(() => process.exit(0), 20)\"", root);
expect(manager.hasPendingProbeTimer()).toBe(true);
await waitFor(() => store.getState().status === "stopped");
expect(manager.hasPendingProbeTimer()).toBe(false);
});
it("restarts with a fresh fallback probe timer", async () => {
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root, { scriptId: "dev" });
expect(manager.hasPendingProbeTimer()).toBe(true);
await manager.restart();
expect(manager.hasPendingProbeTimer()).toBe(true);
});
it("cleanup() kills process and clears listeners", async () => {
const { root, manager } = await createManager();

View File

@@ -98,10 +98,57 @@ describe("createDevServerRouter", () => {
command: "",
cwd: "",
logHistory: [],
previewUrl: null,
detectedPort: null,
manualPreviewUrl: null,
isRunning: false,
});
});
it("GET /api/dev-server/status exposes previewUrl, detectedPort, and manualPreviewUrl", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const store = await loadDevServerStore(root);
await store.updateState({
detectedUrl: "http://localhost:5173",
detectedPort: 5173,
manualUrl: "https://localhost:3000",
});
const app = buildApp(root);
const res = await request(app, "GET", "/api/dev-server/status");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
previewUrl: "https://localhost:3000",
detectedPort: 5173,
manualPreviewUrl: "https://localhost:3000",
});
});
it("GET /api/dev-server/status falls back to detected URL when no manual override exists", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const store = await loadDevServerStore(root);
await store.updateState({
detectedUrl: "http://localhost:4321",
detectedPort: 4321,
manualUrl: undefined,
});
const app = buildApp(root);
const res = await request(app, "GET", "/api/dev-server/status");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
previewUrl: "http://localhost:4321",
detectedPort: 4321,
manualPreviewUrl: null,
});
});
it("POST /api/dev-server/start validates required command", async () => {
const root = createProjectRoot();
tempDirs.push(root);
@@ -365,6 +412,59 @@ describe("createDevServerRouter", () => {
});
});
it("SSE stream forwards url-detected events with the documented payload", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
await withHttpServer(app, async (baseUrl) => {
const streamResponse = await fetch(`${baseUrl}/api/dev-server/logs/stream`);
const reader = streamResponse.body?.getReader();
expect(reader).toBeDefined();
const startResponse = await fetch(`${baseUrl}/api/dev-server/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command: "node -e \"console.log('detected at http://localhost:5173/'); setInterval(() => {}, 1000)\"",
cwd: root,
}),
});
expect(startResponse.status).toBe(200);
let buffered = "";
const startedAt = Date.now();
while (!buffered.includes("event: dev-server:url-detected")) {
if (Date.now() - startedAt > 5_000) {
throw new Error(`Timed out waiting for url-detected event. Current payload: ${buffered}`);
}
const chunk = await reader?.read();
if (!chunk || chunk.done) {
break;
}
buffered += new TextDecoder().decode(chunk.value);
}
expect(buffered).toContain("event: dev-server:url-detected");
const payloadMatch = buffered.match(/event: dev-server:url-detected\ndata: (.+)/);
expect(payloadMatch).toBeTruthy();
const payload = JSON.parse(payloadMatch?.[1] ?? "{}");
expect(payload).toMatchObject({
url: "http://localhost:5173",
port: 5173,
source: "generic-url",
});
expect(typeof payload.detectedAt).toBe("string");
expect(Number.isNaN(Date.parse(payload.detectedAt))).toBe(false);
expect(Object.keys(payload).sort()).toEqual(["detectedAt", "port", "source", "url"]);
await fetch(`${baseUrl}/api/dev-server/stop`, { method: "POST" });
await reader?.cancel();
});
});
it("SSE stream cleans up listeners on client disconnect", async () => {
const root = createProjectRoot();
tempDirs.push(root);
@@ -377,14 +477,16 @@ describe("createDevServerRouter", () => {
await waitFor(() => {
const manager = getActiveProcessManagers()[0];
return (manager?.listenerCount("output") ?? 0) > 0;
return (manager?.listenerCount("output") ?? 0) > 0
&& (manager?.listenerCount("url-detected") ?? 0) > 0;
});
await reader?.cancel();
await waitFor(() => {
const manager = getActiveProcessManagers()[0];
return (manager?.listenerCount("output") ?? 0) === 0;
return (manager?.listenerCount("output") ?? 0) === 0
&& (manager?.listenerCount("url-detected") ?? 0) === 0;
});
});
});

View File

@@ -1,7 +1,7 @@
import { EventEmitter } from "node:events";
import { resolve } from "node:path";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import { DevServerProcessManager } from "./dev-server-process.js";
import { DevServerProcessManager, type DevServerProcessManagerOptions } from "./dev-server-process.js";
import {
loadDevServerStore,
resetDevServerStore,
@@ -15,7 +15,7 @@ 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 = [3000, 4173, 5173, 6006, 8080, 8888, 4000, 4200] as const;
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";
@@ -58,6 +58,13 @@ export interface DevServerSnapshot {
logs: DevServerPersistedLogEntry[];
}
export interface DevServerUrlDetectedEvent {
url: string;
port: number;
source: string;
detectedAt: string;
}
export interface DevServerManagerEvent {
type: "state" | "log";
data: DevServerPersistedState | DevServerPersistedLogEntry;
@@ -65,6 +72,11 @@ export interface DevServerManagerEvent {
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);
@@ -78,11 +90,11 @@ export class DevServerManager extends EventEmitter {
constructor(
private readonly rootDir: string,
private readonly store: DevServerStore,
options?: { logLimit?: number },
options?: DevServerManagerOptions,
) {
super();
this.logLimit = options?.logLimit ?? DEFAULT_LOG_LIMIT;
this.processManager = new DevServerProcessManager(store);
this.processManager = new DevServerProcessManager(store, options?.processOptions);
this.bindProcessEvents();
}
@@ -91,10 +103,10 @@ export class DevServerManager extends EventEmitter {
this.applyDevServerState(state);
});
this.processManager.on("output", (payload: { line: string; timestamp: string }) => {
this.processManager.on("output", (payload: { line: string; stream: "stdout" | "stderr"; timestamp: string }) => {
this.appendLog({
serverKey: this.state.serverKey,
source: "stdout",
source: payload.stream,
message: payload.line,
timestamp: payload.timestamp,
});
@@ -120,8 +132,9 @@ export class DevServerManager extends EventEmitter {
});
});
this.processManager.on("url-detected", () => {
this.processManager.on("url-detected", (payload: DevServerUrlDetectedEvent) => {
this.applyDevServerState(this.store.getState());
this.emit("url-detected", payload);
});
}
@@ -155,6 +168,10 @@ export class DevServerManager extends EventEmitter {
return { ...this.state };
}
hasPendingFallbackProbeTimer(): boolean {
return this.processManager.hasPendingProbeTimer();
}
getRecentLogs(limit = this.logLimit): DevServerPersistedLogEntry[] {
if (!Number.isFinite(limit) || limit <= 0) {
return [];

View File

@@ -0,0 +1,250 @@
import { createConnection, type Socket } from "node:net";
export interface PortDetectionResult {
url: string;
port: number;
source: string;
}
const RESERVED_DASHBOARD_PORT = 4040;
const DEFAULT_PROBE_TIMEOUT_MS = 1_000;
const DEFAULT_PROBE_HOST = "127.0.0.1";
const ANSI_ESCAPE = String.fromCharCode(27);
const ANSI_PATTERN = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, "g");
export const FALLBACK_PREVIEW_PORTS = [5173, 3000, 4173, 6006, 8080, 4200, 4400, 8888, 4321, 4000] as const;
function stripAnsi(value: string): string {
return value.replace(ANSI_PATTERN, "");
}
function isValidPreviewPort(port: number): boolean {
return Number.isInteger(port) && port > 0 && port <= 65_535 && port !== RESERVED_DASHBOARD_PORT;
}
function normalizeUrl(rawUrl: string, fallbackPort?: number): { url: string; port: number } | null {
const trimmed = rawUrl.trim();
if (!trimmed) {
return null;
}
let candidate = trimmed;
if (/^\/\//.test(candidate)) {
candidate = `http:${candidate}`;
} else if (!/^https?:\/\//i.test(candidate)) {
candidate = `http://${candidate}`;
}
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
const hostname = parsed.hostname.toLowerCase();
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
return null;
}
const parsedPort = parsed.port.length > 0 ? Number.parseInt(parsed.port, 10) : Number.NaN;
const port = Number.isFinite(parsedPort) ? parsedPort : fallbackPort;
if (!port || !isValidPreviewPort(port)) {
return null;
}
const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
const normalizedUrl = `${parsed.protocol}//${hostname}:${port}${pathname}${parsed.search}${parsed.hash}`;
return { url: normalizedUrl, port };
}
function withSource(source: string, rawUrl: string, fallbackPort?: number): PortDetectionResult | null {
const normalized = normalizeUrl(rawUrl, fallbackPort);
if (!normalized) {
return null;
}
return {
...normalized,
source,
};
}
function detectViteLine(line: string): PortDetectionResult | null {
const localWithArrowMatch = line.match(/➜\s*Local:\s*(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (localWithArrowMatch) {
return withSource("vite", localWithArrowMatch[1]);
}
const viteUrlMatch = line.match(/\bvite\b[^\n]*?(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (viteUrlMatch) {
return withSource("vite", viteUrlMatch[1]);
}
return null;
}
function detectNextLine(line: string): PortDetectionResult | null {
const nextStartedMatch = line.match(/ready\s*-\s*started server on [^,]+,\s*url:\s*(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (nextStartedMatch) {
return withSource("nextjs", nextStartedMatch[1]);
}
const nextLocalMatch = line.match(/-\s*Local:\s*(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (nextLocalMatch) {
return withSource("nextjs", nextLocalMatch[1]);
}
return null;
}
function detectStorybookLine(line: string): PortDetectionResult | null {
const storybookLocalMatch = line.match(/=>\s*Local:\s*(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (storybookLocalMatch) {
return withSource("storybook", storybookLocalMatch[1]);
}
const storybookUrlMatch = line.match(/\bstorybook\b[^\n]*?(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (storybookUrlMatch) {
return withSource("storybook", storybookUrlMatch[1]);
}
return null;
}
function detectAngularLine(line: string): PortDetectionResult | null {
const angularUrlMatch = line.match(/Angular Live Development Server[^\n]*?(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (angularUrlMatch) {
return withSource("angular", angularUrlMatch[1]);
}
const angularHostPortMatch = line.match(/Angular Live Development Server[^\n]*?(?:localhost|127\.0\.0\.1):(\d{2,5})/i);
if (angularHostPortMatch) {
const port = Number.parseInt(angularHostPortMatch[1], 10);
return withSource("angular", `localhost:${port}`, port);
}
return null;
}
function detectGenericUrl(line: string): PortDetectionResult | null {
const genericUrlMatch = line.match(/((?:https?:\/\/)?(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/\S*)?)/i);
if (!genericUrlMatch) {
return null;
}
return withSource("generic-url", genericUrlMatch[1]);
}
function detectGenericPortLine(line: string): PortDetectionResult | null {
const keywordPortMatch = line.match(/\b(?:ready|listening|started|available|compiled|running|server)\b[^\d]{0,50}(?:on\s+)?(?:port\s*[:=]?\s*)?(\d{2,5})\b/i);
if (!keywordPortMatch) {
return null;
}
const port = Number.parseInt(keywordPortMatch[1], 10);
return withSource("generic-port", `localhost:${port}`, port);
}
function normalizeProbeHost(host: string): string {
const trimmed = host.trim();
if (!trimmed) {
return DEFAULT_PROBE_HOST;
}
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
try {
const parsed = new URL(candidate);
return parsed.hostname || DEFAULT_PROBE_HOST;
} catch {
return trimmed;
}
}
export function detectPortFromLogLine(line: string): PortDetectionResult | null {
if (typeof line !== "string") {
return null;
}
const cleanLine = stripAnsi(line).trim();
if (!cleanLine) {
return null;
}
return (
detectViteLine(cleanLine)
?? detectNextLine(cleanLine)
?? detectStorybookLine(cleanLine)
?? detectAngularLine(cleanLine)
?? detectGenericUrl(cleanLine)
?? detectGenericPortLine(cleanLine)
);
}
export function detectPortFromLogs(lines: string[]): PortDetectionResult | null {
if (!Array.isArray(lines) || lines.length === 0) {
return null;
}
for (let index = lines.length - 1; index >= 0; index -= 1) {
const result = detectPortFromLogLine(lines[index] ?? "");
if (result) {
return result;
}
}
return null;
}
function probePort(host: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const socket: Socket = createConnection({ host, port });
const settle = (isOpen: boolean) => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
if (isOpen) {
socket.end();
} else {
socket.destroy();
}
resolve(isOpen);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => settle(true));
socket.once("timeout", () => settle(false));
socket.once("error", () => settle(false));
});
}
export async function probeFallbackPorts(host = DEFAULT_PROBE_HOST, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS): Promise<PortDetectionResult | null> {
const safeHost = normalizeProbeHost(host);
const safeTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0
? Math.floor(timeoutMs)
: DEFAULT_PROBE_TIMEOUT_MS;
for (const port of FALLBACK_PREVIEW_PORTS) {
if (!isValidPreviewPort(port)) {
continue;
}
// Probe sequentially so first responsive common port wins deterministically.
// eslint-disable-next-line no-await-in-loop
const isOpen = await probePort(safeHost, port, safeTimeout);
if (isOpen) {
return {
url: `http://${safeHost}:${port}`,
port,
source: "fallback-probe",
};
}
}
return null;
}

View File

@@ -1,9 +1,12 @@
import { EventEmitter } from "node:events";
import { spawn, type ChildProcess } from "node:child_process";
import { createConnection } from "node:net";
import type { Socket } from "node:net";
import type { Readable } from "node:stream";
import type { DevServerState, DevServerStore } from "./dev-server-store.js";
import {
detectPortFromLogLine,
probeFallbackPorts,
type PortDetectionResult,
} from "./dev-server-port-detect.js";
export type DevServerEvent =
| "started"
@@ -12,18 +15,26 @@ export type DevServerEvent =
| "failed"
| "url-detected";
interface DevServerProcessManagerOptions {
export interface DevServerProcessManagerOptions {
stopTimeoutMs?: number;
probeDelayMs?: number;
probeTimeoutMs?: number;
}
interface UrlDetectedEventPayload {
url: string;
port: number;
source: string;
detectedAt: string;
}
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
const DEFAULT_PROBE_DELAY_MS = 10_000;
const PROBE_PORTS = [3000, 4173, 5173, 6006, 8080, 8888, 4000, 4200] as const;
const DEFAULT_PROBE_HOST = "127.0.0.1";
const DEFAULT_PROBE_TIMEOUT_MS = 1_000;
export class DevServerProcessManager extends EventEmitter {
private childProcess: ChildProcess | null = null;
private urlDetectionTimer: NodeJS.Timeout | null = null;
private portProbeTimer: NodeJS.Timeout | null = null;
private hasDetectedUrl = false;
private closePromise: Promise<DevServerState> | null = null;
@@ -31,6 +42,7 @@ export class DevServerProcessManager extends EventEmitter {
private readonly stopTimeoutMs: number;
private readonly probeDelayMs: number;
private readonly probeTimeoutMs: number;
constructor(
private readonly store: DevServerStore,
@@ -39,6 +51,7 @@ export class DevServerProcessManager extends EventEmitter {
super();
this.stopTimeoutMs = options?.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
this.probeDelayMs = options?.probeDelayMs ?? DEFAULT_PROBE_DELAY_MS;
this.probeTimeoutMs = options?.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
}
async start(
@@ -95,20 +108,20 @@ export class DevServerProcessManager extends EventEmitter {
let lifecycleSettled = false;
const handleLine = async (line: string): Promise<void> => {
const handleLine = async (line: string, stream: "stdout" | "stderr"): Promise<void> => {
const trimmed = line.replace(/\r$/, "");
if (!trimmed) {
return;
}
await this.store.appendLog(trimmed);
const payload = { line: trimmed, timestamp: new Date().toISOString() };
const payload = { line: trimmed, stream, timestamp: new Date().toISOString() };
this.emit("output", payload);
this.parseUrlFromOutput(trimmed);
void this.handleDetectionFromLine(trimmed);
};
this.attachOutput(child.stdout, handleLine);
this.attachOutput(child.stderr, handleLine);
this.attachOutput(child.stdout, "stdout", handleLine);
this.attachOutput(child.stderr, "stderr", handleLine);
child.on("close", (code) => {
if (lifecycleSettled) {
@@ -126,12 +139,8 @@ export class DevServerProcessManager extends EventEmitter {
void this.handleFailure(err);
});
this.urlDetectionTimer = setTimeout(() => {
this.urlDetectionTimer = null;
}, this.probeDelayMs);
this.portProbeTimer = setTimeout(() => {
void this.probePorts();
void this.runFallbackProbe();
}, this.probeDelayMs);
return runningState;
@@ -190,6 +199,10 @@ export class DevServerProcessManager extends EventEmitter {
return this.childProcess !== null && !this.childProcess.killed;
}
hasPendingProbeTimer(): boolean {
return this.portProbeTimer !== null;
}
cleanup(): void {
this.clearTimers();
@@ -210,7 +223,8 @@ export class DevServerProcessManager extends EventEmitter {
private attachOutput(
stream: Readable | null,
onLine: (line: string) => Promise<void>,
source: "stdout" | "stderr",
onLine: (line: string, source: "stdout" | "stderr") => Promise<void>,
): void {
if (!stream) {
return;
@@ -223,7 +237,7 @@ export class DevServerProcessManager extends EventEmitter {
pending = lines.pop() ?? "";
for (const line of lines) {
void onLine(line);
void onLine(line, source);
}
});
@@ -231,7 +245,7 @@ export class DevServerProcessManager extends EventEmitter {
if (pending.length > 0) {
const line = pending;
pending = "";
void onLine(line);
void onLine(line, source);
}
};
@@ -239,109 +253,60 @@ export class DevServerProcessManager extends EventEmitter {
stream.on("close", flushPending);
}
private parseUrlFromOutput(line: string): void {
private async handleDetectionFromLine(line: string): Promise<void> {
if (this.hasDetectedUrl) {
return;
}
let url: string | undefined;
let port: number | undefined;
const httpMatch = line.match(/http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/i);
if (httpMatch) {
port = Number.parseInt(httpMatch[1], 10);
url = httpMatch[0];
const detected = detectPortFromLogLine(line);
if (!detected) {
return;
}
if (!url) {
const httpsMatch = line.match(/https:\/\/(?:localhost|127\.0\.0\.1):(\d+)/i);
if (httpsMatch) {
port = Number.parseInt(httpsMatch[1], 10);
url = httpsMatch[0];
}
await this.persistDetection(detected);
}
private async runFallbackProbe(): Promise<void> {
this.portProbeTimer = null;
if (this.hasDetectedUrl || !this.isRunning()) {
return;
}
if (!url) {
const keywordPortMatch = line.match(/\b(?:ready|listening|started|available|compiled)\b[^\d]*?(?:port\s+|:)(\d{2,5})/i);
if (keywordPortMatch) {
port = Number.parseInt(keywordPortMatch[1], 10);
url = `http://localhost:${port}`;
}
const detected = await probeFallbackPorts(DEFAULT_PROBE_HOST, this.probeTimeoutMs);
if (!detected || this.hasDetectedUrl || !this.isRunning()) {
return;
}
if (!url || !port || Number.isNaN(port)) {
await this.persistDetection(detected);
}
private async persistDetection(detected: PortDetectionResult): Promise<void> {
if (this.hasDetectedUrl) {
return;
}
this.hasDetectedUrl = true;
this.clearProbeTimer();
void this.store.updateState({ detectedUrl: url, detectedPort: port })
.then((state) => {
this.emit("url-detected", { url: state.detectedUrl, port: state.detectedPort });
})
.catch(() => {
this.hasDetectedUrl = false;
const detectedAt = new Date().toISOString();
try {
const updated = await this.store.updateState({
detectedUrl: detected.url,
detectedPort: detected.port,
});
}
private async probePorts(): Promise<void> {
if (this.hasDetectedUrl || !this.isRunning()) {
return;
}
let foundPort: number | null = null;
const activeSockets = new Set<Socket>();
const probePromises = PROBE_PORTS.map((port) => new Promise<number>((resolve, reject) => {
const socket = createConnection({ host: "127.0.0.1", port, timeout: 1000 });
activeSockets.add(socket);
const cleanup = () => {
activeSockets.delete(socket);
socket.removeAllListeners();
const payload: UrlDetectedEventPayload = {
url: updated.detectedUrl ?? detected.url,
port: updated.detectedPort ?? detected.port,
source: detected.source,
detectedAt,
};
socket.once("connect", () => {
cleanup();
socket.end();
resolve(port);
});
socket.once("timeout", () => {
cleanup();
socket.destroy();
reject(new Error(`timeout:${port}`));
});
socket.once("error", (error) => {
cleanup();
reject(error);
});
socket.once("close", () => {
cleanup();
});
}).then((port) => {
if (foundPort === null) {
foundPort = port;
for (const socket of activeSockets) {
socket.destroy();
}
}
return port;
}));
await Promise.allSettled(probePromises);
if (foundPort === null || this.hasDetectedUrl) {
return;
this.emit("url-detected", payload);
} catch {
this.hasDetectedUrl = false;
}
this.hasDetectedUrl = true;
const detectedUrl = `http://localhost:${foundPort}`;
const updated = await this.store.updateState({ detectedUrl, detectedPort: foundPort });
this.emit("url-detected", { url: updated.detectedUrl, port: updated.detectedPort });
}
private async handleClose(code: number): Promise<void> {
@@ -383,10 +348,6 @@ export class DevServerProcessManager extends EventEmitter {
}
private clearTimers(): void {
if (this.urlDetectionTimer) {
clearTimeout(this.urlDetectionTimer);
this.urlDetectionTimer = null;
}
this.clearProbeTimer();
}
}

View File

@@ -5,6 +5,7 @@ import {
loadDevServerStore,
resetDevServerStore,
type DevServerConfig,
type DevServerState,
type DevServerStore,
} from "./dev-server-store.js";
import { DevServerProcessManager } from "./dev-server-process.js";
@@ -138,6 +139,19 @@ function parseConfigUpdateBody(body: unknown): Partial<DevServerConfig> {
return partial;
}
function buildStatusResponse(state: DevServerState, isRunning: boolean) {
const manualPreviewUrl = state.manualUrl ?? null;
const detectedPreviewUrl = state.detectedUrl ?? null;
return {
...state,
previewUrl: manualPreviewUrl ?? detectedPreviewUrl,
detectedPort: state.detectedPort ?? null,
manualPreviewUrl,
isRunning,
};
}
export function createDevServerRouter(options: DevServerRouterOptions): Router {
const router = Router();
@@ -187,7 +201,8 @@ export function createDevServerRouter(options: DevServerRouterOptions): Router {
try {
const { store, manager } = await getRuntime(options.projectRoot);
const state = store.getState();
res.json({ ...state, isRunning: manager.isRunning() });
res.json(buildStatusResponse(state, manager.isRunning()));
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load dev server status";
res.status(500).json({ error: message });
@@ -315,7 +330,7 @@ export function createDevServerRouter(options: DevServerRouterOptions): Router {
return;
}
const onOutput = (payload: { line: string; timestamp: string }) => {
const onOutput = (payload: { line: string; stream: "stdout" | "stderr"; timestamp: string }) => {
writeSSE(res, `event: log\ndata: ${JSON.stringify(payload)}\n\n`);
};
@@ -327,9 +342,14 @@ export function createDevServerRouter(options: DevServerRouterOptions): Router {
writeSSE(res, `event: failed\ndata: ${JSON.stringify(payload)}\n\n`);
};
const onUrlDetected = (payload: { url: string; port: number; source: string; detectedAt: string }) => {
writeSSE(res, `event: dev-server:url-detected\ndata: ${JSON.stringify(payload)}\n\n`);
};
manager.on("output", onOutput);
manager.on("stopped", onStopped);
manager.on("failed", onFailed);
manager.on("url-detected", onUrlDetected);
const heartbeat = setInterval(() => {
writeSSE(res, ": heartbeat\n\n");
@@ -345,6 +365,7 @@ export function createDevServerRouter(options: DevServerRouterOptions): Router {
manager.off("output", onOutput);
manager.off("stopped", onStopped);
manager.off("failed", onFailed);
manager.off("url-detected", onUrlDetected);
};
req.on("close", cleanup);