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:
173
packages/dashboard/src/__tests__/dev-server-manager.test.ts
Normal file
173
packages/dashboard/src/__tests__/dev-server-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
241
packages/dashboard/src/__tests__/dev-server-port-detect.test.ts
Normal file
241
packages/dashboard/src/__tests__/dev-server-port-detect.test.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
250
packages/dashboard/src/dev-server-port-detect.ts
Normal file
250
packages/dashboard/src/dev-server-port-detect.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user