@mingxy/cerebro 1.20.2 โ 1.20.4
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/index.ts +10 -8
- package/src/logger.ts +3 -3
- package/src/updater.ts +2 -3
- package/src/web-server.ts +180 -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.4",
|
|
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/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { CerebroClient } from "./client.js";
|
|
7
|
-
import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHook,
|
|
7
|
+
import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHook, sessionMessages, firstMessages, showToast } from "./hooks.js";
|
|
8
8
|
import { detectSaveKeyword, detectRecallKeyword, KEYWORD_NUDGE, RECALL_NUDGE } from "./keywords.js";
|
|
9
9
|
import { getUserTag, getProjectTag } from "./tags.js";
|
|
10
10
|
import { buildTools } from "./tools.js";
|
|
@@ -64,7 +64,7 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
64
64
|
} catch {}
|
|
65
65
|
|
|
66
66
|
const config = loadPluginConfig(overrides as any);
|
|
67
|
-
const
|
|
67
|
+
const STARTUP_DELAY = 2000;
|
|
68
68
|
|
|
69
69
|
setOpencodeClient(client);
|
|
70
70
|
|
|
@@ -78,18 +78,20 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
78
78
|
logError(`Connection failed: ${errMsg}`);
|
|
79
79
|
if (errMsg.includes("[cerebro]")) {
|
|
80
80
|
const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
|
|
81
|
-
|
|
81
|
+
showToast(
|
|
82
82
|
tui,
|
|
83
83
|
`๐ง Cerebro v${pluginVersion} ยท Server Error`,
|
|
84
84
|
cleanMsg.substring(0, 150),
|
|
85
|
-
"error"
|
|
85
|
+
"error",
|
|
86
|
+
STARTUP_DELAY
|
|
86
87
|
);
|
|
87
88
|
} else {
|
|
88
|
-
|
|
89
|
+
showToast(
|
|
89
90
|
tui,
|
|
90
91
|
`๐ง Cerebro v${pluginVersion} ยท Connection Failed`,
|
|
91
92
|
`Unable to reach ${config.connection.apiUrl}`,
|
|
92
|
-
"error"
|
|
93
|
+
"error",
|
|
94
|
+
STARTUP_DELAY
|
|
93
95
|
);
|
|
94
96
|
}
|
|
95
97
|
}
|
|
@@ -125,9 +127,9 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
if (webPort) {
|
|
128
|
-
|
|
130
|
+
showToast(tui, `๐ง Cerebro Connected ยท v${pluginVersion}`, `๐ Open in browser http://localhost:${webPort}`, "success", STARTUP_DELAY);
|
|
129
131
|
} else {
|
|
130
|
-
|
|
132
|
+
showToast(tui, `๐ง Cerebro Connected ยท v${pluginVersion}`, "No web server", "success", STARTUP_DELAY);
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
// Auto-update check (fire-and-forget, non-blocking)
|
package/src/logger.ts
CHANGED
|
@@ -138,11 +138,11 @@ function writeLog(level: string, message: string, fields?: Record<string, unknow
|
|
|
138
138
|
appendFileSync(logFile, parts.join(" ") + "\n");
|
|
139
139
|
} catch {}
|
|
140
140
|
|
|
141
|
-
// Track 2: opencode client
|
|
141
|
+
// Track 2: opencode client (async โ fire-and-forget with .catch)
|
|
142
142
|
try {
|
|
143
143
|
opencodeClient?.app?.log({
|
|
144
|
-
body: { service: "cerebro", level: level.toLowerCase(), message, extra: fields },
|
|
145
|
-
});
|
|
144
|
+
body: { service: "cerebro", level: level.toLowerCase() as "debug" | "info" | "warn" | "error", message, extra: fields },
|
|
145
|
+
})?.catch?.(() => {});
|
|
146
146
|
} catch { /* opencode client not available, skip */ }
|
|
147
147
|
}
|
|
148
148
|
|
package/src/updater.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
6
6
|
import { openSync, closeSync, unlinkSync, statSync, writeSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
7
7
|
import { loadPluginConfig } from "./config.js";
|
|
8
8
|
import { logInfo, logDebug, logError } from "./logger.js";
|
|
9
|
+
import { showToast } from "./hooks.js";
|
|
9
10
|
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
@@ -142,9 +143,7 @@ export async function checkAndUpdate(tui: any, currentVersion: string): Promise<
|
|
|
142
143
|
if (success) {
|
|
143
144
|
logInfo("updater: update completed", { from: currentVersion, to: latest });
|
|
144
145
|
try {
|
|
145
|
-
|
|
146
|
-
body: { message: `Cerebro updated to v${latest} โ restart opencode to apply`, variant: "info" }
|
|
147
|
-
});
|
|
146
|
+
showToast(tui, "๐ง Cerebro Updated", `v${currentVersion} โ v${latest} ยท restart opencode to apply`, "info", 2000);
|
|
148
147
|
} catch {}
|
|
149
148
|
} else {
|
|
150
149
|
logError("updater: update failed", { targetDir });
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
}
|
|
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 ๆไปถไธๅญๅจ๏ผ็ปง็ปญ */ }
|
|
68
|
+
let activeServerHandle: WebServerHandle | null = null;
|
|
69
|
+
let takeoverTimer: ReturnType<typeof setInterval> | null = null;
|
|
70
|
+
let takeoverRetries = 0;
|
|
99
71
|
|
|
100
|
-
|
|
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,166 @@ export async function startWebServer(
|
|
|
108
82
|
return null;
|
|
109
83
|
}
|
|
110
84
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
return createHandle(port);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handle = await tryBind(port, webDir, config.apiUrl);
|
|
91
|
+
if (handle) {
|
|
92
|
+
activeServerHandle = handle;
|
|
93
|
+
return createHandle(port);
|
|
94
|
+
}
|
|
130
95
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
96
|
+
logInfo("web-server: port busy, starting takeover watch", { port });
|
|
97
|
+
startTakeoverWatch(port, webDir, config.apiUrl);
|
|
98
|
+
return createHandle(port);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function tryBind(port: number, webDir: string, apiUrl: string): Promise<WebServerHandle | null> {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const indexPath = path.join(webDir, "index.html");
|
|
134
104
|
|
|
135
|
-
|
|
105
|
+
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
106
|
+
if (req.url === "/health" || req.url === "/health/") {
|
|
107
|
+
res.writeHead(200, { ...COMMON_HEADERS, "Content-Type": "application/json" });
|
|
108
|
+
res.end(JSON.stringify({ status: "ok", service: "cerebro", port }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
136
111
|
|
|
137
|
-
|
|
138
|
-
|
|
112
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
113
|
+
res.writeHead(405, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
114
|
+
res.end("Method Not Allowed");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
139
117
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const timeout = setTimeout(() => resolve(false), 5000);
|
|
118
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
119
|
+
const safePath = resolveSafe(webDir, url.pathname);
|
|
143
120
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
} else if (msg.type === "error") {
|
|
149
|
-
clearTimeout(timeout);
|
|
150
|
-
resolve(false);
|
|
121
|
+
if (!safePath) {
|
|
122
|
+
res.writeHead(403, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
123
|
+
res.end("Forbidden");
|
|
124
|
+
return;
|
|
151
125
|
}
|
|
126
|
+
|
|
127
|
+
fs.stat(safePath, (statErr, stats) => {
|
|
128
|
+
if (!statErr && stats.isFile()) {
|
|
129
|
+
serveFile(res, safePath, apiUrl);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
fs.stat(indexPath, (idxErr, idxStats) => {
|
|
133
|
+
if (idxErr || !idxStats.isFile()) {
|
|
134
|
+
res.writeHead(404, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
135
|
+
res.end("Not Found");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
serveFile(res, indexPath, apiUrl);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
152
141
|
});
|
|
153
142
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
143
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
144
|
+
if (err.code === "EADDRINUSE") {
|
|
145
|
+
resolve(null);
|
|
146
|
+
} else {
|
|
147
|
+
logError("web-server: bind error", { error: err.message });
|
|
148
|
+
resolve(null);
|
|
149
|
+
}
|
|
157
150
|
});
|
|
158
151
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
resolve(
|
|
152
|
+
server.listen(port, "127.0.0.1", () => {
|
|
153
|
+
logInfo("web-server: server started", { port });
|
|
154
|
+
resolve({
|
|
155
|
+
address: () => ({ port, family: "IPv4", address: "127.0.0.1" }),
|
|
156
|
+
isOwner: () => true,
|
|
157
|
+
stop: () => new Promise<void>((r) => server.close(() => r())),
|
|
158
|
+
});
|
|
162
159
|
});
|
|
163
160
|
});
|
|
161
|
+
}
|
|
164
162
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
163
|
+
function serveFile(res: http.ServerResponse, filePath: string, apiUrl: string): void {
|
|
164
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
165
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
166
|
+
|
|
167
|
+
fs.readFile(filePath, (err, data) => {
|
|
168
|
+
if (err) {
|
|
169
|
+
logError("web-server: file read failed", { filePath, error: err.message });
|
|
170
|
+
res.writeHead(500, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
|
|
171
|
+
res.end("Internal Server Error");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let body: Buffer | string = data;
|
|
176
|
+
if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
|
|
177
|
+
body = data.toString("utf-8").replace(
|
|
178
|
+
/window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/,
|
|
179
|
+
`window.__OMEM_API_URL__ = ${JSON.stringify(apiUrl)}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
res.writeHead(200, {
|
|
184
|
+
...COMMON_HEADERS,
|
|
185
|
+
"Content-Type": contentType,
|
|
186
|
+
"Cache-Control": ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=86400",
|
|
187
|
+
});
|
|
188
|
+
res.end(body);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
171
191
|
|
|
172
|
-
|
|
173
|
-
|
|
192
|
+
function clearTakeoverTimer(): void {
|
|
193
|
+
if (takeoverTimer) {
|
|
194
|
+
clearInterval(takeoverTimer);
|
|
195
|
+
takeoverTimer = null;
|
|
196
|
+
}
|
|
174
197
|
}
|
|
175
198
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
199
|
+
function startTakeoverWatch(port: number, webDir: string, apiUrl: string): void {
|
|
200
|
+
if (takeoverTimer) return;
|
|
201
|
+
takeoverRetries = 0;
|
|
202
|
+
takeoverTimer = setInterval(async () => {
|
|
203
|
+
if (await probeExistingServer(port)) {
|
|
204
|
+
takeoverRetries = 0;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
takeoverRetries++;
|
|
209
|
+
if (takeoverRetries > TAKEOVER_MAX_RETRIES) {
|
|
210
|
+
logWarn("web-server: takeover abandoned after max retries", { port });
|
|
211
|
+
clearTakeoverTimer();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
183
214
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
215
|
+
const jitter = TAKEOVER_JITTER_MIN_MS + Math.random() * TAKEOVER_JITTER_RANGE_MS;
|
|
216
|
+
await new Promise((r) => setTimeout(r, jitter));
|
|
217
|
+
if (await probeExistingServer(port)) {
|
|
218
|
+
takeoverRetries = 0;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
188
221
|
|
|
189
|
-
|
|
190
|
-
|
|
222
|
+
const handle = await tryBind(port, webDir, apiUrl);
|
|
223
|
+
if (handle) {
|
|
224
|
+
activeServerHandle = handle;
|
|
225
|
+
logInfo("web-server: takeover successful", { port });
|
|
226
|
+
clearTakeoverTimer();
|
|
227
|
+
}
|
|
228
|
+
}, TAKEOVER_INTERVAL_MS);
|
|
229
|
+
takeoverTimer.unref();
|
|
230
|
+
}
|
|
191
231
|
|
|
232
|
+
function createHandle(port: number): WebServerHandle {
|
|
192
233
|
return {
|
|
193
|
-
address() {
|
|
194
|
-
|
|
234
|
+
address: () => ({ port, family: "IPv4", address: "127.0.0.1" }),
|
|
235
|
+
isOwner: () => activeServerHandle?.isOwner() ?? false,
|
|
236
|
+
stop: () => {
|
|
237
|
+
clearTakeoverTimer();
|
|
238
|
+
const h = activeServerHandle;
|
|
239
|
+
activeServerHandle = null;
|
|
240
|
+
return h ? h.stop() : Promise.resolve();
|
|
195
241
|
},
|
|
196
242
|
};
|
|
197
243
|
}
|
|
198
244
|
|
|
199
|
-
export function stopWebServer(
|
|
200
|
-
|
|
201
|
-
// heartbeat ages out โ child detects mtime > 60s โ self-terminate.
|
|
202
|
-
return Promise.resolve();
|
|
245
|
+
export function stopWebServer(handle: WebServerHandle): Promise<void> {
|
|
246
|
+
return handle.stop();
|
|
203
247
|
}
|
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
|
-
});
|