@mingxy/cerebro 1.19.3 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * web-server-child.ts — Cerebro Web Server Child Process
3
+ *
4
+ * 独立 fork 入口。实际 HTTP server 运行在此进程中。
5
+ * 通过 IPC 从父进程接收配置,启动后通知父进程 ready。
6
+ * 通过心跳文件 mtime 探测 plugin 进程存活状态。
7
+ */
8
+ import * as http from "node:http";
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import * as os from "node:os";
12
+
13
+ // ── Lightweight file logger for child process ──────────────────────────
14
+ // Cannot import logger.ts (no opencodeClient + circular dependency risk).
15
+ // Writes to cerebro-web-child.log in the configured log directory.
16
+
17
+ const CHILD_LOG_FILE = path.join(
18
+ process.env.OMEM_LOG_DIR || path.join(os.homedir(), ".config", "cerebro", "logs"),
19
+ "cerebro-web-child.log",
20
+ );
21
+
22
+ function childLog(level: string, message: string, fields?: Record<string, unknown>): void {
23
+ try {
24
+ const dir = path.dirname(CHILD_LOG_FILE);
25
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
26
+ const now = new Date();
27
+ const ts = now.toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
28
+ const parts = [`${level.padEnd(5)} ${ts} service=cerebro-web-child`];
29
+ if (fields) {
30
+ for (const [k, v] of Object.entries(fields)) {
31
+ parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
32
+ }
33
+ }
34
+ parts.push(message);
35
+ fs.appendFileSync(CHILD_LOG_FILE, parts.join(" ") + "\n");
36
+ } catch { /* best effort */ }
37
+ }
38
+
39
+ // ── Types ────────────────────────────────────────────────────────────────
40
+
41
+ interface ChildConfig {
42
+ port: number;
43
+ webDir: string;
44
+ apiUrl: string;
45
+ }
46
+
47
+ // ── MIME map ─────────────────────────────────────────────────────────────
48
+
49
+ const MIME_TYPES: Record<string, string> = {
50
+ ".html": "text/html; charset=utf-8",
51
+ ".js": "application/javascript; charset=utf-8",
52
+ ".mjs": "application/javascript; charset=utf-8",
53
+ ".css": "text/css; charset=utf-8",
54
+ ".json": "application/json; charset=utf-8",
55
+ ".svg": "image/svg+xml",
56
+ ".png": "image/png",
57
+ ".jpg": "image/jpeg",
58
+ ".jpeg": "image/jpeg",
59
+ ".gif": "image/gif",
60
+ ".ico": "image/x-icon",
61
+ ".woff": "font/woff",
62
+ ".woff2": "font/woff2",
63
+ ".ttf": "font/ttf",
64
+ ".eot": "application/vnd.ms-fontobject",
65
+ ".webp": "image/webp",
66
+ ".webmanifest": "application/manifest+json",
67
+ ".map": "application/json",
68
+ ".txt": "text/plain; charset=utf-8",
69
+ };
70
+
71
+ function getMimeType(ext: string): string {
72
+ return MIME_TYPES[ext] || "application/octet-stream";
73
+ }
74
+
75
+ // ── Safe path resolver (prevent traversal) ───────────────────────────────
76
+
77
+ function resolveSafe(baseDir: string, pathname: string): string | null {
78
+ const relative = pathname.startsWith("/") ? pathname.slice(1) : pathname;
79
+ const resolved = path.resolve(baseDir, relative || ".");
80
+ if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
81
+ return null;
82
+ }
83
+ return resolved;
84
+ }
85
+
86
+ // ── File serving ─────────────────────────────────────────────────────────
87
+
88
+ function serveFile(
89
+ res: http.ServerResponse,
90
+ filePath: string,
91
+ apiUrl: string,
92
+ ): void {
93
+ const ext = path.extname(filePath).toLowerCase();
94
+ const contentType = getMimeType(ext);
95
+
96
+ fs.readFile(filePath, (err, data) => {
97
+ if (err) {
98
+ res.writeHead(500, { "Content-Type": "text/plain" });
99
+ res.end("Internal Server Error");
100
+ return;
101
+ }
102
+
103
+ let body: Buffer | string = data;
104
+
105
+ // Config injection: replace placeholder in index.html
106
+ if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
107
+ body = data
108
+ .toString("utf-8")
109
+ .replace(
110
+ /window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/,
111
+ `window.__OMEM_API_URL__ = "${apiUrl}"`,
112
+ );
113
+ }
114
+
115
+ res.writeHead(200, {
116
+ "Content-Type": contentType,
117
+ "Cache-Control":
118
+ ext === ".html"
119
+ ? "no-cache, no-store, must-revalidate"
120
+ : "public, max-age=86400",
121
+ });
122
+ res.end(body);
123
+ });
124
+ }
125
+
126
+ // ── Server lifecycle ─────────────────────────────────────────────────────
127
+
128
+ let server: http.Server | null = null;
129
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
130
+ let pidFilePath = "";
131
+ let heartbeatFilePath = "";
132
+
133
+ function cleanup(): void {
134
+ if (heartbeatTimer) {
135
+ clearInterval(heartbeatTimer);
136
+ heartbeatTimer = null;
137
+ }
138
+ if (server) {
139
+ server.closeAllConnections?.();
140
+ const forceTimer = setTimeout(() => {
141
+ try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
142
+ process.exit(0);
143
+ }, 3000);
144
+ server.close(() => {
145
+ clearTimeout(forceTimer);
146
+ try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
147
+ process.exit(0);
148
+ });
149
+ } else {
150
+ try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
151
+ process.exit(0);
152
+ }
153
+ }
154
+
155
+ function startServer(config: ChildConfig): void {
156
+ const { port, webDir, apiUrl } = config;
157
+
158
+ pidFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.pid`);
159
+ heartbeatFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.heartbeat`);
160
+
161
+ const indexPath = path.join(webDir, "index.html");
162
+
163
+ // 写 PID 文件(子进程自己的 PID)
164
+ try {
165
+ fs.writeFileSync(pidFilePath, String(process.pid));
166
+ } catch {
167
+ childLog("ERROR", "Failed to write PID file");
168
+ }
169
+
170
+ // 初始 touch 心跳文件
171
+ try {
172
+ fs.writeFileSync(heartbeatFilePath, "");
173
+ } catch {
174
+ childLog("ERROR", "Failed to create heartbeat file");
175
+ }
176
+
177
+ server = http.createServer(
178
+ (req: http.IncomingMessage, res: http.ServerResponse) => {
179
+ // ── /health 端点 ──
180
+ if (req.url === "/health" || req.url === "/health/") {
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(JSON.stringify({ status: "ok", service: "cerebro", port }));
183
+ return;
184
+ }
185
+
186
+ // Only handle GET / HEAD
187
+ if (req.method !== "GET" && req.method !== "HEAD") {
188
+ res.writeHead(405, { "Content-Type": "text/plain" });
189
+ res.end("Method Not Allowed");
190
+ return;
191
+ }
192
+
193
+ // Parse URL, strip query string
194
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
195
+ // new URL() already decodes percent-encoding; no double-decode
196
+ const pathname = url.pathname;
197
+
198
+ // Resolve safe file path
199
+ const safePath = resolveSafe(webDir, pathname);
200
+
201
+ if (!safePath) {
202
+ res.writeHead(403, { "Content-Type": "text/plain" });
203
+ res.end("Forbidden");
204
+ return;
205
+ }
206
+
207
+ // Try to serve the file directly
208
+ fs.stat(safePath, (statErr, stats) => {
209
+ if (!statErr && stats.isFile()) {
210
+ serveFile(res, safePath, apiUrl);
211
+ return;
212
+ }
213
+
214
+ // SPA fallback: serve index.html for non-file paths
215
+ fs.stat(indexPath, (idxErr, idxStats) => {
216
+ if (idxErr || !idxStats.isFile()) {
217
+ res.writeHead(404, { "Content-Type": "text/plain" });
218
+ res.end("Not Found");
219
+ return;
220
+ }
221
+ serveFile(res, indexPath, apiUrl);
222
+ });
223
+ });
224
+ },
225
+ );
226
+
227
+ server.on("error", (err: NodeJS.ErrnoException) => {
228
+ childLog("ERROR", "Server error", { error: err.message });
229
+ process.send?.({ type: "error", message: err.message });
230
+ cleanup();
231
+ });
232
+
233
+ server.listen(port, "127.0.0.1", () => {
234
+ childLog("INFO", "Server listening", { port });
235
+ process.send?.({ type: "ready", port });
236
+ });
237
+
238
+ // ── 心跳检测:每 30 秒检查心跳文件 mtime ──
239
+ heartbeatTimer = setInterval(() => {
240
+ try {
241
+ const stat = fs.statSync(heartbeatFilePath);
242
+ if (Date.now() - stat.mtimeMs > 60_000) {
243
+ childLog("INFO", "Heartbeat expired, shutting down");
244
+ cleanup();
245
+ }
246
+ } catch {
247
+ // 心跳文件不存在,可能被清理,继续等待不主动退出
248
+ }
249
+ }, 30_000);
250
+
251
+ // 确保定时器不阻止进程退出
252
+ if (heartbeatTimer) heartbeatTimer.unref();
253
+
254
+ // ── 信号处理 ──
255
+ process.on("SIGTERM", cleanup);
256
+ process.on("SIGINT", cleanup);
257
+ }
258
+
259
+ // ── IPC 监听(只处理第一条消息) ─────────────────────────────────────────
260
+
261
+ let started = false;
262
+
263
+ process.on("message", (config: ChildConfig) => {
264
+ if (started) return;
265
+ started = true;
266
+ startServer(config);
267
+ });
package/src/web-server.ts CHANGED
@@ -1,7 +1,16 @@
1
- import * as http from "node:http";
1
+ /**
2
+ * web-server.ts — Cerebro Web Server Manager (spawn mode)
3
+ *
4
+ * 不再直接创建 HTTP server,而是 fork 独立子进程 (web-server-child.ts)。
5
+ * 多窗口共享一个 server,任一窗口关闭不影响其他窗口。
6
+ * 子进程通过心跳文件 mtime 探测 plugin 存活状态,全部退出后自动关闭。
7
+ */
8
+ import { fork } from "node:child_process";
2
9
  import * as fs from "node:fs";
3
10
  import * as path from "node:path";
11
+ import * as os from "node:os";
4
12
  import { fileURLToPath } from "node:url";
13
+ import { logInfo, logWarn, logError } from "./logger.js";
5
14
 
6
15
  const __filename = fileURLToPath(import.meta.url);
7
16
  const __dirname = path.dirname(__filename);
@@ -13,170 +22,178 @@ export interface WebServerConfig {
13
22
  port?: number;
14
23
  }
15
24
 
16
- // ── MIME map ─────────────────────────────────────────────────────────────
17
-
18
- const MIME_TYPES: Record<string, string> = {
19
- ".html": "text/html; charset=utf-8",
20
- ".js": "application/javascript; charset=utf-8",
21
- ".mjs": "application/javascript; charset=utf-8",
22
- ".css": "text/css; charset=utf-8",
23
- ".json": "application/json; charset=utf-8",
24
- ".svg": "image/svg+xml",
25
- ".png": "image/png",
26
- ".jpg": "image/jpeg",
27
- ".jpeg": "image/jpeg",
28
- ".gif": "image/gif",
29
- ".ico": "image/x-icon",
30
- ".woff": "font/woff",
31
- ".woff2": "font/woff2",
32
- ".ttf": "font/ttf",
33
- ".eot": "application/vnd.ms-fontobject",
34
- ".webp": "image/webp",
35
- ".webmanifest": "application/manifest+json",
36
- ".map": "application/json",
37
- ".txt": "text/plain; charset=utf-8",
38
- };
39
-
40
- function getMimeType(ext: string): string {
41
- return MIME_TYPES[ext] || "application/octet-stream";
25
+ export interface WebServerHandle {
26
+ address(): { port: number; family: string; address: string } | string | null;
42
27
  }
43
28
 
44
- // ── Safe path resolver (prevent traversal) ───────────────────────────────
29
+ // ── Helpers ──────────────────────────────────────────────────────────────
45
30
 
46
- function resolveSafe(baseDir: string, pathname: string): string | null {
47
- // Strip leading slash so path.resolve treats it as relative
48
- const relative = pathname.startsWith("/") ? pathname.slice(1) : pathname;
49
- const resolved = path.resolve(baseDir, relative || ".");
50
- if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
51
- return null;
31
+ /** Touch a file update mtime or create if missing */
32
+ function touchFile(filePath: string): void {
33
+ try {
34
+ fs.utimesSync(filePath, new Date(), new Date());
35
+ } catch {
36
+ try {
37
+ fs.writeFileSync(filePath, "");
38
+ } catch { /* ignore */ }
52
39
  }
53
- return resolved;
40
+ }
41
+
42
+ /** Check if a process with the given PID is still running */
43
+ function isProcessAlive(pid: number): boolean {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /** Probe an existing server's /health endpoint */
53
+ async function probeExistingServer(port: number): Promise<boolean> {
54
+ try {
55
+ const resp = await fetch(`http://127.0.0.1:${port}/health`);
56
+ if (resp.ok) {
57
+ const body = await resp.text();
58
+ return body.includes("cerebro");
59
+ }
60
+ } catch { /* connection refused → port free */ }
61
+ return false;
54
62
  }
55
63
 
56
64
  // ── Start / Stop ─────────────────────────────────────────────────────────
57
65
 
58
- export function startWebServer(config: WebServerConfig): Promise<http.Server | null> {
59
- return new Promise((resolve) => {
60
- const webDir = path.resolve(__dirname, "..", "web");
66
+ export async function startWebServer(
67
+ config: WebServerConfig,
68
+ ): Promise<WebServerHandle | null> {
69
+ const port =
70
+ config.port || parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || 5212;
71
+ const pidFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.pid`);
72
+ const heartbeatFilePath = path.join(
73
+ os.tmpdir(),
74
+ `cerebro-web-${port}.heartbeat`,
75
+ );
76
+
77
+ // ── Step 1: 检查端口是否已有 cerebro server ──
78
+ if (await probeExistingServer(port)) {
79
+ logInfo("web-server: reusing existing server", { port });
80
+ return createHandle(port, heartbeatFilePath);
81
+ }
61
82
 
62
- // Check web directory exists
63
- if (!fs.existsSync(webDir)) {
64
- console.warn(`[cerebro:web] Web directory not found: ${webDir}, skipping server start`);
65
- resolve(null);
66
- return;
83
+ // ── Step 2: 检查 PID 文件(可能有其他进程正在 fork) ──
84
+ try {
85
+ const pidStr = fs.readFileSync(pidFilePath, "utf-8").trim();
86
+ const pid = parseInt(pidStr, 10);
87
+ if (pid > 0 && isProcessAlive(pid)) {
88
+ // 有其他进程正在 fork 或运行,等待 200ms 后重试
89
+ await new Promise((r) => setTimeout(r, 200));
90
+ if (await probeExistingServer(port)) {
91
+ logInfo("web-server: reusing server after PID check", { port });
92
+ return createHandle(port, heartbeatFilePath);
93
+ }
94
+ } else {
95
+ // 进程已死亡,清理 PID 文件
96
+ try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
67
97
  }
98
+ } catch { /* PID 文件不存在,继续 */ }
68
99
 
69
- const indexPath = path.join(webDir, "index.html");
70
- if (!fs.existsSync(indexPath)) {
71
- console.warn(`[cerebro:web] index.html not found in ${webDir}, skipping server start`);
72
- resolve(null);
73
- return;
74
- }
100
+ // ── Step 3: 检查 web 目录 ──
101
+ const webDir = path.resolve(__dirname, "..", "web");
102
+ if (!fs.existsSync(webDir)) {
103
+ logWarn("web-server: web directory not found, skipping", { webDir });
104
+ return null;
105
+ }
106
+ if (!fs.existsSync(path.join(webDir, "index.html"))) {
107
+ logWarn("web-server: index.html not found, skipping", { webDir });
108
+ return null;
109
+ }
75
110
 
76
- const port = config.port || parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || 5212;
77
-
78
- const server = http.createServer(
79
- (req: http.IncomingMessage, res: http.ServerResponse) => {
80
- // Only handle GET / HEAD
81
- if (req.method !== "GET" && req.method !== "HEAD") {
82
- res.writeHead(405, { "Content-Type": "text/plain" });
83
- res.end("Method Not Allowed");
84
- return;
85
- }
86
-
87
- // Parse URL, strip query string
88
- const url = new URL(req.url || "/", `http://localhost:${port}`);
89
- const pathname = decodeURIComponent(url.pathname);
90
-
91
- // Resolve safe file path
92
- const safePath = resolveSafe(webDir, pathname);
93
-
94
- if (!safePath) {
95
- res.writeHead(403, { "Content-Type": "text/plain" });
96
- res.end("Forbidden");
97
- return;
98
- }
99
-
100
- // Try to serve the file directly
101
- fs.stat(safePath, (statErr, stats) => {
102
- if (!statErr && stats.isFile()) {
103
- serveFile(res, safePath, config.apiUrl);
104
- return;
105
- }
106
-
107
- // SPA fallback: serve index.html for non-file paths
108
- fs.stat(indexPath, (idxErr, idxStats) => {
109
- if (idxErr || !idxStats.isFile()) {
110
- res.writeHead(404, { "Content-Type": "text/plain" });
111
- res.end("Not Found");
112
- return;
113
- }
114
- serveFile(res, indexPath, config.apiUrl);
115
- });
116
- });
117
- },
118
- );
119
-
120
- server.on("error", (err: NodeJS.ErrnoException) => {
121
- if (err.code === "EADDRINUSE") {
122
- console.warn(`[cerebro:web] Port ${port} already in use, web server not started`);
123
- } else {
124
- console.warn(`[cerebro:web] Server error: ${err.message}`);
125
- }
126
- resolve(null);
127
- });
111
+ // ── Step 4: PID 文件(标记正在 fork) ──
112
+ try {
113
+ fs.writeFileSync(pidFilePath, String(process.pid));
114
+ } catch { /* ignore */ }
128
115
 
129
- server.listen(port, "127.0.0.1", () => {
130
- const addr = server.address();
131
- const actualPort = typeof addr === "object" && addr ? addr.port : port;
132
- console.log(`[cerebro:web] Static server listening on http://localhost:${actualPort}`);
133
- resolve(server);
134
- });
116
+ // ── Step 5: Fork 子进程 ──
117
+ // TS runtime: .ts exists; compiled: .js exists
118
+ const childTs = path.resolve(__dirname, "web-server-child.ts");
119
+ const childJs = path.resolve(__dirname, "web-server-child.js");
120
+ const childPath = fs.existsSync(childTs) ? childTs : childJs;
121
+
122
+ const child = fork(childPath, [], {
123
+ detached: true,
124
+ stdio: ["pipe", "pipe", "pipe", "ipc"],
135
125
  });
136
- }
137
126
 
138
- // ── File serving ─────────────────────────────────────────────────────────
139
-
140
- function serveFile(
141
- res: http.ServerResponse,
142
- filePath: string,
143
- apiUrl: string,
144
- ): void {
145
- const ext = path.extname(filePath).toLowerCase();
146
- const contentType = getMimeType(ext);
147
-
148
- fs.readFile(filePath, (err, data) => {
149
- if (err) {
150
- res.writeHead(500, { "Content-Type": "text/plain" });
151
- res.end("Internal Server Error");
152
- return;
153
- }
127
+ // Drain stdout/stderr to prevent pipe buffer from blocking the child
128
+ child.stdout?.on("data", () => {});
129
+ child.stderr?.on("data", () => {});
154
130
 
155
- let body: Buffer | string = data;
131
+ child.unref();
156
132
 
157
- // Config injection: replace placeholder in index.html
158
- if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
159
- body = data.toString("utf-8").replace(/window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/, `window.__OMEM_API_URL__ = "${apiUrl}"`);
160
- }
133
+ // 发送配置给子进程
134
+ child.send({ port, webDir, apiUrl: config.apiUrl });
135
+
136
+ // ── Step 6: 等待 ready 或超时 5s ──
137
+ const ready = await new Promise<boolean>((resolve) => {
138
+ const timeout = setTimeout(() => resolve(false), 5000);
161
139
 
162
- res.writeHead(200, {
163
- "Content-Type": contentType,
164
- "Cache-Control": ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=86400",
140
+ child.on("message", (msg: { type: string }) => {
141
+ if (msg.type === "ready") {
142
+ clearTimeout(timeout);
143
+ resolve(true);
144
+ } else if (msg.type === "error") {
145
+ clearTimeout(timeout);
146
+ resolve(false);
147
+ }
165
148
  });
166
- res.end(body);
167
- });
168
- }
169
149
 
170
- // ── Graceful shutdown ────────────────────────────────────────────────────
150
+ child.on("error", () => {
151
+ clearTimeout(timeout);
152
+ resolve(false);
153
+ });
171
154
 
172
- export function stopWebServer(server: http.Server): Promise<void> {
173
- return new Promise((resolve) => {
174
- server.closeAllConnections?.();
175
- const timer = setTimeout(resolve, 3000);
176
- server.close(() => {
177
- clearTimeout(timer);
178
- console.log("[cerebro:web] Server stopped");
179
- resolve();
155
+ child.on("exit", () => {
156
+ clearTimeout(timeout);
157
+ resolve(false);
180
158
  });
181
159
  });
160
+
161
+ if (!ready) {
162
+ logError("web-server: failed to start child process", { port });
163
+ try { child.kill(); } catch { /* ignore */ }
164
+ try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
165
+ return null;
166
+ }
167
+
168
+ logInfo("web-server: child process started", { port, pid: child.pid });
169
+ return createHandle(port, heartbeatFilePath);
170
+ }
171
+
172
+ /** Create a handle with address() compat + heartbeat keep-alive */
173
+ function createHandle(
174
+ port: number,
175
+ heartbeatFilePath: string,
176
+ ): WebServerHandle {
177
+ // 初始 touch
178
+ touchFile(heartbeatFilePath);
179
+
180
+ // 每 30 秒 touch 心跳文件,保持子进程存活
181
+ const timer = setInterval(() => {
182
+ touchFile(heartbeatFilePath);
183
+ }, 30_000);
184
+
185
+ // 确保定时器不阻止进程退出
186
+ timer.unref();
187
+
188
+ return {
189
+ address() {
190
+ return { port, family: "IPv4", address: "127.0.0.1" };
191
+ },
192
+ };
193
+ }
194
+
195
+ export function stopWebServer(_handle: WebServerHandle): Promise<void> {
196
+ // Intentionally do NOT touch heartbeat: parent exits → timer stops →
197
+ // heartbeat ages out → child detects mtime > 60s → self-terminate.
198
+ return Promise.resolve();
182
199
  }