@mingxy/cerebro 1.20.3 → 1.20.5
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.
- package/package.json +1 -1
- package/src/web-server.ts +186 -136
- package/src/web-server-child.ts +0 -267
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingxy/cerebro",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.5",
|
|
4
4
|
"description": "Cerebro persistent memory plugin for OpenCode — auto-recall, auto-capture, 9 memory tools with clustering, project-scoped memory isolation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/web-server.ts
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
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";
|
|
1
|
+
import * as http from "node:http";
|
|
9
2
|
import * as fs from "node:fs";
|
|
10
3
|
import * as path from "node:path";
|
|
11
|
-
import * as os from "node:os";
|
|
12
4
|
import { fileURLToPath } from "node:url";
|
|
13
5
|
import { logInfo, logWarn, logError } from "./logger.js";
|
|
14
6
|
|
|
15
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
8
|
const __dirname = path.dirname(__filename);
|
|
17
9
|
|
|
18
|
-
|
|
10
|
+
const DEFAULT_PORT = 5212;
|
|
11
|
+
const TAKEOVER_INTERVAL_MS = 5000;
|
|
12
|
+
const TAKEOVER_JITTER_MIN_MS = 500;
|
|
13
|
+
const TAKEOVER_JITTER_RANGE_MS = 1000;
|
|
14
|
+
const TAKEOVER_MAX_RETRIES = 60;
|
|
19
15
|
|
|
20
16
|
export interface WebServerConfig {
|
|
21
17
|
apiUrl: string;
|
|
@@ -24,32 +20,40 @@ export interface WebServerConfig {
|
|
|
24
20
|
|
|
25
21
|
export interface WebServerHandle {
|
|
26
22
|
address(): { port: number; family: string; address: string } | string | null;
|
|
23
|
+
isOwner(): boolean;
|
|
24
|
+
stop(): Promise<void>;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
27
|
+
const MIME_TYPES: Record<string, string> = {
|
|
28
|
+
".html": "text/html; charset=utf-8",
|
|
29
|
+
".js": "application/javascript; charset=utf-8",
|
|
30
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
31
|
+
".css": "text/css; charset=utf-8",
|
|
32
|
+
".json": "application/json; charset=utf-8",
|
|
33
|
+
".svg": "image/svg+xml",
|
|
34
|
+
".png": "image/png",
|
|
35
|
+
".jpg": "image/jpeg",
|
|
36
|
+
".ico": "image/x-icon",
|
|
37
|
+
".woff": "font/woff",
|
|
38
|
+
".woff2": "font/woff2",
|
|
39
|
+
".ttf": "font/ttf",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
".map": "application/json",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const COMMON_HEADERS: Record<string, string> = {
|
|
45
|
+
"X-Content-Type-Options": "nosniff",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function resolveSafe(baseDir: string, pathname: string): string | null {
|
|
49
|
+
const relative = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
50
|
+
const resolved = path.resolve(baseDir, relative || ".");
|
|
51
|
+
if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
|
|
52
|
+
return null;
|
|
49
53
|
}
|
|
54
|
+
return resolved;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
/** Probe an existing server's /health endpoint */
|
|
53
57
|
async function probeExistingServer(port: number): Promise<boolean> {
|
|
54
58
|
try {
|
|
55
59
|
const resp = await fetch(`http://127.0.0.1:${port}/health`);
|
|
@@ -57,48 +61,18 @@ async function probeExistingServer(port: number): Promise<boolean> {
|
|
|
57
61
|
const body = await resp.text();
|
|
58
62
|
return body.includes("cerebro");
|
|
59
63
|
}
|
|
60
|
-
} catch {
|
|
64
|
+
} catch {}
|
|
61
65
|
return false;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
let activeServerHandle: WebServerHandle | null = null;
|
|
69
|
+
let takeoverTimer: ReturnType<typeof setInterval> | null = null;
|
|
70
|
+
let takeoverRetries = 0;
|
|
65
71
|
|
|
66
|
-
export async function startWebServer(
|
|
67
|
-
config
|
|
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
|
-
}
|
|
82
|
-
|
|
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 */ }
|
|
97
|
-
}
|
|
98
|
-
} catch { /* PID 文件不存在,继续 */ }
|
|
99
|
-
|
|
100
|
-
// ── Step 3: 检查 web 目录 ──
|
|
72
|
+
export async function startWebServer(config: WebServerConfig): Promise<WebServerHandle | null> {
|
|
73
|
+
const port = config.port || parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || DEFAULT_PORT;
|
|
101
74
|
const webDir = path.resolve(__dirname, "..", "web");
|
|
75
|
+
|
|
102
76
|
if (!fs.existsSync(webDir)) {
|
|
103
77
|
logWarn("web-server: web directory not found, skipping", { webDir });
|
|
104
78
|
return null;
|
|
@@ -108,96 +82,172 @@ export async function startWebServer(
|
|
|
108
82
|
return null;
|
|
109
83
|
}
|
|
110
84
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
// Use the same executor as the parent process (tsx, node, etc.)
|
|
123
|
-
// so the child can run .ts files. Also inherit execArgv (--import tsx, etc.)
|
|
124
|
-
const child = fork(childPath, [], {
|
|
125
|
-
detached: true,
|
|
126
|
-
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
127
|
-
execPath: process.execPath,
|
|
128
|
-
execArgv: process.execArgv,
|
|
129
|
-
});
|
|
85
|
+
if (await probeExistingServer(port)) {
|
|
86
|
+
logInfo("web-server: reusing existing server", { port });
|
|
87
|
+
startTakeoverWatch(port, webDir, config.apiUrl);
|
|
88
|
+
return createHandle(port);
|
|
89
|
+
}
|
|
130
90
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
91
|
+
const handle = await tryBind(port, webDir, config.apiUrl);
|
|
92
|
+
if (handle) {
|
|
93
|
+
activeServerHandle = handle;
|
|
94
|
+
return createHandle(port);
|
|
95
|
+
}
|
|
134
96
|
|
|
135
|
-
|
|
97
|
+
logInfo("web-server: port busy, starting takeover watch", { port });
|
|
98
|
+
startTakeoverWatch(port, webDir, config.apiUrl);
|
|
99
|
+
return createHandle(port);
|
|
100
|
+
}
|
|
136
101
|
|
|
137
|
-
|
|
138
|
-
|
|
102
|
+
function tryBind(port: number, webDir: string, apiUrl: string): Promise<WebServerHandle | null> {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const indexPath = path.join(webDir, "index.html");
|
|
139
105
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
106
|
+
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
107
|
+
if (req.url === "/health" || req.url === "/health/") {
|
|
108
|
+
res.writeHead(200, { ...COMMON_HEADERS, "Content-Type": "application/json" });
|
|
109
|
+
res.end(JSON.stringify({ status: "ok", service: "cerebro", port }));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
143
112
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
} else if (msg.type === "error") {
|
|
149
|
-
clearTimeout(timeout);
|
|
150
|
-
resolve(false);
|
|
113
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
114
|
+
res.writeHead(405, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
115
|
+
res.end("Method Not Allowed");
|
|
116
|
+
return;
|
|
151
117
|
}
|
|
118
|
+
|
|
119
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
120
|
+
const safePath = resolveSafe(webDir, url.pathname);
|
|
121
|
+
|
|
122
|
+
if (!safePath) {
|
|
123
|
+
res.writeHead(403, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
124
|
+
res.end("Forbidden");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fs.stat(safePath, (statErr, stats) => {
|
|
129
|
+
if (!statErr && stats.isFile()) {
|
|
130
|
+
serveFile(res, safePath, apiUrl);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
fs.stat(indexPath, (idxErr, idxStats) => {
|
|
134
|
+
if (idxErr || !idxStats.isFile()) {
|
|
135
|
+
res.writeHead(404, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
136
|
+
res.end("Not Found");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
serveFile(res, indexPath, apiUrl);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
152
142
|
});
|
|
153
143
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
144
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
145
|
+
if (err.code === "EADDRINUSE") {
|
|
146
|
+
resolve(null);
|
|
147
|
+
} else {
|
|
148
|
+
logError("web-server: bind error", { error: err.message });
|
|
149
|
+
resolve(null);
|
|
150
|
+
}
|
|
157
151
|
});
|
|
158
152
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
resolve(
|
|
153
|
+
server.listen(port, "127.0.0.1", () => {
|
|
154
|
+
logInfo("web-server: server started", { port });
|
|
155
|
+
resolve({
|
|
156
|
+
address: () => ({ port, family: "IPv4", address: "127.0.0.1" }),
|
|
157
|
+
isOwner: () => true,
|
|
158
|
+
stop: () => new Promise<void>((r) => server.close(() => r())),
|
|
159
|
+
});
|
|
162
160
|
});
|
|
163
161
|
});
|
|
162
|
+
}
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
function serveFile(res: http.ServerResponse, filePath: string, apiUrl: string): void {
|
|
165
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
166
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
167
|
+
|
|
168
|
+
fs.readFile(filePath, (err, data) => {
|
|
169
|
+
if (err) {
|
|
170
|
+
logError("web-server: file read failed", { filePath, error: err.message });
|
|
171
|
+
res.writeHead(500, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
172
|
+
res.end("Internal Server Error");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let body: Buffer | string = data;
|
|
177
|
+
if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
|
|
178
|
+
body = data.toString("utf-8").replace(
|
|
179
|
+
/window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/,
|
|
180
|
+
`window.__OMEM_API_URL__ = ${JSON.stringify(apiUrl)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
171
183
|
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
res.writeHead(200, {
|
|
185
|
+
...COMMON_HEADERS,
|
|
186
|
+
"Content-Type": contentType,
|
|
187
|
+
"Cache-Control": ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=86400",
|
|
188
|
+
});
|
|
189
|
+
res.end(body);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function clearTakeoverTimer(): void {
|
|
194
|
+
if (takeoverTimer) {
|
|
195
|
+
clearInterval(takeoverTimer);
|
|
196
|
+
takeoverTimer = null;
|
|
197
|
+
}
|
|
174
198
|
}
|
|
175
199
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
200
|
+
function startTakeoverWatch(port: number, webDir: string, apiUrl: string): void {
|
|
201
|
+
if (takeoverTimer) return;
|
|
202
|
+
takeoverRetries = 0;
|
|
203
|
+
takeoverTimer = setInterval(async () => {
|
|
204
|
+
if (await probeExistingServer(port)) {
|
|
205
|
+
takeoverRetries = 0;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
takeoverRetries++;
|
|
210
|
+
if (takeoverRetries > TAKEOVER_MAX_RETRIES) {
|
|
211
|
+
logWarn("web-server: takeover abandoned after max retries", { port });
|
|
212
|
+
clearTakeoverTimer();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
183
215
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
216
|
+
const jitter = TAKEOVER_JITTER_MIN_MS + Math.random() * TAKEOVER_JITTER_RANGE_MS;
|
|
217
|
+
await new Promise((r) => setTimeout(r, jitter));
|
|
218
|
+
if (await probeExistingServer(port)) {
|
|
219
|
+
takeoverRetries = 0;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
188
222
|
|
|
189
|
-
|
|
190
|
-
|
|
223
|
+
const handle = await tryBind(port, webDir, apiUrl);
|
|
224
|
+
if (handle) {
|
|
225
|
+
activeServerHandle = handle;
|
|
226
|
+
logInfo("web-server: takeover successful", { port });
|
|
227
|
+
clearTakeoverTimer();
|
|
228
|
+
}
|
|
229
|
+
}, TAKEOVER_INTERVAL_MS);
|
|
230
|
+
takeoverTimer.unref();
|
|
231
|
+
}
|
|
191
232
|
|
|
233
|
+
function createHandle(port: number): WebServerHandle {
|
|
192
234
|
return {
|
|
193
|
-
address() {
|
|
194
|
-
|
|
235
|
+
address: () => ({ port, family: "IPv4", address: "127.0.0.1" }),
|
|
236
|
+
isOwner: () => activeServerHandle?.isOwner() ?? false,
|
|
237
|
+
stop: () => {
|
|
238
|
+
if (activeServerHandle?.isOwner()) {
|
|
239
|
+
clearTakeoverTimer();
|
|
240
|
+
const h = activeServerHandle;
|
|
241
|
+
activeServerHandle = null;
|
|
242
|
+
return h.stop();
|
|
243
|
+
}
|
|
244
|
+
clearTakeoverTimer();
|
|
245
|
+
activeServerHandle = null;
|
|
246
|
+
return Promise.resolve();
|
|
195
247
|
},
|
|
196
248
|
};
|
|
197
249
|
}
|
|
198
250
|
|
|
199
|
-
export function stopWebServer(
|
|
200
|
-
|
|
201
|
-
// heartbeat ages out → child detects mtime > 60s → self-terminate.
|
|
202
|
-
return Promise.resolve();
|
|
251
|
+
export function stopWebServer(handle: WebServerHandle): Promise<void> {
|
|
252
|
+
return handle.stop();
|
|
203
253
|
}
|
package/src/web-server-child.ts
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
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
|
-
});
|