Skip to content

Commit d1268db

Browse files
committed
refactor(cli): extract debug server into a feature module
Move the debug HTTP server out of `utils/` (it's a subsystem, not a small utility) into `cli/debug-server/` and decompose the former 167-line `createDebugServer` god-factory: - `create-debug-server.ts` — lifecycle only (lock reuse, listen, lock write, exit cleanup). - `ingest-request-listener.ts` — CORS + a route table over the health check and `/ingest/:sessionId` POST/GET/DELETE, with a `writeJson` helper replacing the repeated writeHead/end(JSON.stringify) blocks. - `debug-session-store.ts` — per-session state, id parsing/validation, and bounded dedup memory. - `server-lock.ts` / `ping-server.ts` — moved unchanged. No behavior change; addresses the thermo code-quality P1.
1 parent 707659b commit d1268db

8 files changed

Lines changed: 321 additions & 251 deletions

File tree

packages/react-doctor/src/cli/commands/debug.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type { Server } from "node:http";
33
import { Command } from "commander";
44
import { highlighter } from "@react-doctor/core";
55
import { cliLogger as logger } from "../utils/cli-logger.js";
6+
import { createDebugServer, type DebugServerInfo } from "../debug-server/index.js";
67
import { DEBUG_DEFAULT_HOST } from "../utils/constants.js";
7-
import { createDebugServer, type DebugServerInfo } from "../utils/debug-server.js";
88
import { spinner } from "../utils/spinner.js";
99

1010
interface DebugCommandOptions {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import crypto from "node:crypto";
2+
import fs from "node:fs";
3+
import http from "node:http";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import {
7+
DEBUG_DEFAULT_HOST,
8+
DEBUG_LOG_DIRECTORY_NAME,
9+
DEBUG_SESSION_ID_BYTE_LENGTH,
10+
} from "../utils/constants.js";
11+
import { SESSION_ID_PATTERN } from "./debug-session-store.js";
12+
import { createIngestRequestListener } from "./ingest-request-listener.js";
13+
import { pingDebugServer } from "./ping-server.js";
14+
import { readDebugServerLock, removeDebugServerLock, writeDebugServerLock } from "./server-lock.js";
15+
16+
export interface DebugServerOptions {
17+
sessionId?: string;
18+
cwd?: string;
19+
logPath?: string;
20+
host?: string;
21+
port?: number;
22+
}
23+
24+
export interface DebugServerInfo {
25+
sessionId: string;
26+
port: number;
27+
endpoint: string;
28+
logPath: string;
29+
}
30+
31+
export interface DebugServerResult {
32+
server: http.Server | null;
33+
info: DebugServerInfo;
34+
reused: boolean;
35+
}
36+
37+
export const createDebugServer = async (
38+
options: DebugServerOptions = {},
39+
): Promise<DebugServerResult> => {
40+
if (options.sessionId !== undefined && !SESSION_ID_PATTERN.test(options.sessionId)) {
41+
throw new Error(
42+
"Invalid --session-id: only letters, digits, '-' and '_' are allowed (no path separators).",
43+
);
44+
}
45+
const sessionId =
46+
options.sessionId || crypto.randomBytes(DEBUG_SESSION_ID_BYTE_LENGTH).toString("hex");
47+
const logDirectory = path.join(options.cwd || os.tmpdir(), DEBUG_LOG_DIRECTORY_NAME);
48+
const primaryLogPath = options.logPath || path.join(logDirectory, `debug-${sessionId}.log`);
49+
const host = options.host || DEBUG_DEFAULT_HOST;
50+
const requestedPort = options.port || 0;
51+
52+
if (!fs.existsSync(logDirectory)) fs.mkdirSync(logDirectory, { recursive: true });
53+
54+
const existingLock = readDebugServerLock(logDirectory);
55+
if (existingLock) {
56+
const isAlive = await pingDebugServer(existingLock.host, existingLock.port);
57+
if (isAlive) {
58+
return {
59+
server: null,
60+
info: {
61+
sessionId: existingLock.sessionId,
62+
port: existingLock.port,
63+
endpoint: existingLock.endpoint,
64+
logPath: existingLock.logPath,
65+
},
66+
reused: true,
67+
};
68+
}
69+
removeDebugServerLock(logDirectory);
70+
}
71+
72+
const server = http.createServer(
73+
createIngestRequestListener({ primarySessionId: sessionId, primaryLogPath, logDirectory }),
74+
);
75+
76+
return new Promise<DebugServerResult>((resolve, reject) => {
77+
server.listen(requestedPort, host, () => {
78+
const serverAddress = server.address();
79+
if (!serverAddress || typeof serverAddress === "string") {
80+
reject(new Error("Failed to get debug server address"));
81+
return;
82+
}
83+
84+
const info: DebugServerInfo = {
85+
sessionId,
86+
port: serverAddress.port,
87+
endpoint: `http://${host}:${serverAddress.port}/ingest/${sessionId}`,
88+
logPath: primaryLogPath,
89+
};
90+
91+
writeDebugServerLock(logDirectory, {
92+
pid: process.pid,
93+
host,
94+
port: serverAddress.port,
95+
sessionId,
96+
endpoint: info.endpoint,
97+
logPath: primaryLogPath,
98+
});
99+
100+
server.on("close", () => removeDebugServerLock(logDirectory));
101+
// SIGINT runs the CLI's `exitGracefully` handler, which calls
102+
// `process.exit` before the server's `close` event can fire, so wire
103+
// lock removal to `exit` (always fires) to avoid a stale lock file.
104+
process.once("exit", () => removeDebugServerLock(logDirectory));
105+
106+
resolve({ server, info, reused: false });
107+
});
108+
server.on("error", reject);
109+
});
110+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import path from "node:path";
2+
import { DEBUG_MAX_DEDUP_ENTRIES } from "../utils/constants.js";
3+
4+
export interface DebugSessionState {
5+
logPath: string;
6+
processedEntryIds: Set<string>;
7+
}
8+
9+
export interface DebugSessionStore {
10+
get(requestSessionId: string): DebugSessionState;
11+
}
12+
13+
interface DebugSessionStoreOptions {
14+
primarySessionId: string;
15+
primaryLogPath: string;
16+
logDirectory: string;
17+
}
18+
19+
// Session ids index a per-session log filename (`debug-<id>.log`), so they
20+
// must stay within the log directory: only word chars, dash, underscore —
21+
// no path separators or `..`.
22+
export const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
23+
24+
export const parseIngestSessionId = (url: string): string | null => {
25+
try {
26+
const { pathname } = new URL(url, "http://localhost");
27+
const match = pathname.match(/^\/ingest\/([a-zA-Z0-9_-]+)\/?$/);
28+
return match ? match[1] : null;
29+
} catch {
30+
return null;
31+
}
32+
};
33+
34+
// Remember an entry id for de-duplication, bounding memory by dropping the
35+
// whole set once it grows past the cap (best-effort: a repeat after a reset
36+
// can slip through, which is acceptable for a debug session).
37+
export const rememberProcessedEntryId = (state: DebugSessionState, entryId: string): void => {
38+
if (state.processedEntryIds.size >= DEBUG_MAX_DEDUP_ENTRIES) {
39+
state.processedEntryIds.clear();
40+
}
41+
state.processedEntryIds.add(entryId);
42+
};
43+
44+
export const createDebugSessionStore = (options: DebugSessionStoreOptions): DebugSessionStore => {
45+
const sessions = new Map<string, DebugSessionState>();
46+
47+
const get = (requestSessionId: string): DebugSessionState => {
48+
const existing = sessions.get(requestSessionId);
49+
if (existing) return existing;
50+
51+
const logPath =
52+
requestSessionId === options.primarySessionId
53+
? options.primaryLogPath
54+
: path.join(options.logDirectory, `debug-${requestSessionId}.log`);
55+
const state: DebugSessionState = { logPath, processedEntryIds: new Set() };
56+
sessions.set(requestSessionId, state);
57+
return state;
58+
};
59+
60+
return { get };
61+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { createDebugServer } from "./create-debug-server.js";
2+
export type {
3+
DebugServerInfo,
4+
DebugServerOptions,
5+
DebugServerResult,
6+
} from "./create-debug-server.js";
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import fs from "node:fs";
2+
import type { IncomingMessage, ServerResponse } from "node:http";
3+
import {
4+
createDebugSessionStore,
5+
parseIngestSessionId,
6+
rememberProcessedEntryId,
7+
type DebugSessionState,
8+
} from "./debug-session-store.js";
9+
10+
interface IngestRequestListenerOptions {
11+
primarySessionId: string;
12+
primaryLogPath: string;
13+
logDirectory: string;
14+
}
15+
16+
interface IngestLogEntry {
17+
id?: string;
18+
sessionId?: string;
19+
timestamp?: number;
20+
}
21+
22+
const writeJson = (
23+
response: ServerResponse,
24+
statusCode: number,
25+
payload: Record<string, unknown>,
26+
): void => {
27+
response.writeHead(statusCode, { "Content-Type": "application/json" });
28+
response.end(JSON.stringify(payload));
29+
};
30+
31+
const setCorsHeaders = (response: ServerResponse): void => {
32+
response.setHeader("Access-Control-Allow-Origin", "*");
33+
response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
34+
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
35+
};
36+
37+
const handleIngestPost = (
38+
request: IncomingMessage,
39+
response: ServerResponse,
40+
state: DebugSessionState,
41+
requestSessionId: string,
42+
): void => {
43+
let requestBody = "";
44+
request.on("data", (chunk: Buffer) => (requestBody += chunk));
45+
request.on("end", () => {
46+
let logEntry: IngestLogEntry;
47+
try {
48+
logEntry = JSON.parse(requestBody);
49+
} catch {
50+
writeJson(response, 400, { error: "Invalid JSON" });
51+
return;
52+
}
53+
54+
if (logEntry.id && state.processedEntryIds.has(logEntry.id)) {
55+
writeJson(response, 200, { ok: true, duplicate: true });
56+
return;
57+
}
58+
59+
logEntry.sessionId = logEntry.sessionId || requestSessionId;
60+
logEntry.timestamp = logEntry.timestamp || Date.now();
61+
try {
62+
fs.appendFileSync(state.logPath, JSON.stringify(logEntry) + "\n");
63+
} catch {
64+
writeJson(response, 500, { error: "Failed to write log" });
65+
return;
66+
}
67+
68+
if (logEntry.id) rememberProcessedEntryId(state, logEntry.id);
69+
writeJson(response, 200, { ok: true });
70+
});
71+
request.on("error", () => {
72+
if (!response.writableEnded) writeJson(response, 400, { error: "Request error" });
73+
});
74+
};
75+
76+
const handleIngestGet = (response: ServerResponse, state: DebugSessionState): void => {
77+
try {
78+
const logContent = fs.existsSync(state.logPath) ? fs.readFileSync(state.logPath, "utf-8") : "";
79+
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
80+
response.end(logContent);
81+
} catch {
82+
response.writeHead(500, { "Content-Type": "text/plain" });
83+
response.end("Failed to read log");
84+
}
85+
};
86+
87+
const handleIngestDelete = (response: ServerResponse, state: DebugSessionState): void => {
88+
try {
89+
if (fs.existsSync(state.logPath)) fs.unlinkSync(state.logPath);
90+
state.processedEntryIds.clear();
91+
writeJson(response, 200, { ok: true, cleared: true });
92+
} catch {
93+
writeJson(response, 500, { error: "Failed to clear log" });
94+
}
95+
};
96+
97+
// Build the `http.createServer` request handler: CORS + a small route table
98+
// over the health check and the `/ingest/:sessionId` POST/GET/DELETE surface.
99+
export const createIngestRequestListener = (
100+
options: IngestRequestListenerOptions,
101+
): ((request: IncomingMessage, response: ServerResponse) => void) => {
102+
const store = createDebugSessionStore(options);
103+
104+
return (request, response) => {
105+
setCorsHeaders(response);
106+
107+
if (request.method === "OPTIONS") {
108+
response.writeHead(204).end();
109+
return;
110+
}
111+
112+
const url = request.url || "/";
113+
114+
if (url === "/" && request.method === "GET") {
115+
writeJson(response, 200, { ok: true });
116+
return;
117+
}
118+
119+
const requestSessionId = parseIngestSessionId(url);
120+
if (!requestSessionId) {
121+
writeJson(response, 404, { error: "Not found" });
122+
return;
123+
}
124+
125+
const state = store.get(requestSessionId);
126+
127+
if (request.method === "POST") {
128+
handleIngestPost(request, response, state, requestSessionId);
129+
return;
130+
}
131+
if (request.method === "GET") {
132+
handleIngestGet(response, state);
133+
return;
134+
}
135+
if (request.method === "DELETE") {
136+
handleIngestDelete(response, state);
137+
return;
138+
}
139+
140+
response.writeHead(405).end();
141+
};
142+
};

packages/react-doctor/src/cli/utils/ping-debug-server.ts renamed to packages/react-doctor/src/cli/debug-server/ping-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import http from "node:http";
2-
import { DEBUG_LOCK_PING_TIMEOUT_MS } from "./constants.js";
2+
import { DEBUG_LOCK_PING_TIMEOUT_MS } from "../utils/constants.js";
33

44
// Confirm the listener at host:port is actually a debug server, not just any
55
// process that happened to bind the lock's port: require a 200 whose body is

packages/react-doctor/src/cli/utils/debug-server-lock.ts renamed to packages/react-doctor/src/cli/debug-server/server-lock.ts

File renamed without changes.

0 commit comments

Comments
 (0)