@mingxy/cerebro 1.19.3 → 1.20.0
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/config.ts +3 -0
- package/src/hooks.ts +9 -2
- package/src/index.ts +7 -4
- package/src/logger.ts +160 -66
- package/src/updater.ts +125 -0
- package/src/web-server-child.ts +245 -0
- package/src/web-server.ts +166 -143
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingxy/cerebro",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
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/config.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface CerebroPluginConfig {
|
|
|
39
39
|
};
|
|
40
40
|
agentMemoryPolicy?: Record<string, "none" | "readonly" | "readwrite">;
|
|
41
41
|
defaultPolicy?: "none" | "readonly" | "readwrite";
|
|
42
|
+
autoUpdate?: boolean; // default false
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
// ── Defaults ─────────────────────────────────────────────────────────
|
|
@@ -75,6 +76,7 @@ const DEFAULTS: CerebroPluginConfig = {
|
|
|
75
76
|
web: {
|
|
76
77
|
enabled: true,
|
|
77
78
|
},
|
|
79
|
+
autoUpdate: false,
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
// ── Flat-to-nested migration ─────────────────────────────────────────
|
|
@@ -146,6 +148,7 @@ function deepMerge(base: CerebroPluginConfig, overrides: Partial<CerebroPluginCo
|
|
|
146
148
|
result.web = { ...base.web!, ...overrides.web };
|
|
147
149
|
if (overrides.agentMemoryPolicy) result.agentMemoryPolicy = overrides.agentMemoryPolicy;
|
|
148
150
|
if (overrides.defaultPolicy) result.defaultPolicy = overrides.defaultPolicy;
|
|
151
|
+
if (overrides.autoUpdate !== undefined) result.autoUpdate = overrides.autoUpdate;
|
|
149
152
|
return result;
|
|
150
153
|
}
|
|
151
154
|
|
package/src/hooks.ts
CHANGED
|
@@ -226,6 +226,8 @@ interface InjectionResult {
|
|
|
226
226
|
profileCount: number;
|
|
227
227
|
memoryCount: number;
|
|
228
228
|
projectMemoryCount: number;
|
|
229
|
+
maxScore: number;
|
|
230
|
+
confidence: number;
|
|
229
231
|
}
|
|
230
232
|
|
|
231
233
|
export async function buildMemoryInjection(
|
|
@@ -297,11 +299,16 @@ export async function buildMemoryInjection(
|
|
|
297
299
|
text = text.slice(0, cutoff > 0 ? cutoff : maxChars) + "\n…\n[/CEREBRO-MEMORY]";
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
const maxScore = searchResults.reduce((max, r) => Math.max(max, r.score), 0);
|
|
303
|
+
const confidence = Math.min(maxScore, 1.0);
|
|
304
|
+
|
|
300
305
|
return {
|
|
301
306
|
text,
|
|
302
307
|
profileCount: profile?.preference_count ?? 0,
|
|
303
308
|
memoryCount: dedupedResults?.length ?? 0,
|
|
304
309
|
projectMemoryCount: projectMemories.length,
|
|
310
|
+
maxScore,
|
|
311
|
+
confidence,
|
|
305
312
|
};
|
|
306
313
|
}
|
|
307
314
|
|
|
@@ -371,8 +378,8 @@ export function chatMessageRecallHook(
|
|
|
371
378
|
session_id: input.sessionID,
|
|
372
379
|
recall_type: "auto",
|
|
373
380
|
query_text: query,
|
|
374
|
-
max_score:
|
|
375
|
-
llm_confidence:
|
|
381
|
+
max_score: injection.maxScore,
|
|
382
|
+
llm_confidence: injection.confidence,
|
|
376
383
|
profile_injected: injection.profileCount > 0,
|
|
377
384
|
kept_count: injection.projectMemoryCount + injection.memoryCount,
|
|
378
385
|
discarded_count: 0,
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import type { Server } from "node:http";
|
|
7
6
|
import { CerebroClient } from "./client.js";
|
|
8
7
|
import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHook, createToast, sessionMessages, firstMessages } from "./hooks.js";
|
|
9
8
|
import { detectSaveKeyword, detectRecallKeyword, KEYWORD_NUDGE, RECALL_NUDGE } from "./keywords.js";
|
|
@@ -11,7 +10,8 @@ import { getUserTag, getProjectTag } from "./tags.js";
|
|
|
11
10
|
import { buildTools } from "./tools.js";
|
|
12
11
|
import { logInfo, logDebug, logError } from "./logger.js";
|
|
13
12
|
import { loadPluginConfig, resolveAgentPolicy } from "./config.js";
|
|
14
|
-
import {
|
|
13
|
+
import { checkAndUpdate } from "./updater.js";
|
|
14
|
+
import { startWebServer, stopWebServer, type WebServerHandle } from "./web-server.js";
|
|
15
15
|
|
|
16
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
17
|
const __dirname = dirname(__filename);
|
|
@@ -103,7 +103,7 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
103
103
|
|
|
104
104
|
const chatMessageRecall = chatMessageRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
|
|
105
105
|
|
|
106
|
-
let webServer:
|
|
106
|
+
let webServer: WebServerHandle | null = null;
|
|
107
107
|
const webEnabled = config.web?.enabled !== false;
|
|
108
108
|
let webPort: number | undefined;
|
|
109
109
|
if (webEnabled) {
|
|
@@ -125,9 +125,12 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
125
125
|
if (webPort) {
|
|
126
126
|
toast(tui, `🧠 Cerebro Connected · v${pluginVersion}`, `🌐 Open in browser http://localhost:${webPort}`, "success");
|
|
127
127
|
} else {
|
|
128
|
-
toast(tui,
|
|
128
|
+
toast(tui, `🧠 Cerebro Connected · v${pluginVersion}`, "No web server", "success");
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Auto-update check (fire-and-forget, non-blocking)
|
|
132
|
+
checkAndUpdate(tui, pluginVersion).catch(() => {});
|
|
133
|
+
|
|
131
134
|
const shutdown = async () => {
|
|
132
135
|
try {
|
|
133
136
|
if (webServer) {
|
package/src/logger.ts
CHANGED
|
@@ -1,66 +1,160 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
statSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { loadPluginConfig } from "./config.js";
|
|
12
|
+
|
|
13
|
+
// ── Level map ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const LEVEL_MAP: Record<string, number> = {
|
|
16
|
+
DEBUG: 0,
|
|
17
|
+
INFO: 1,
|
|
18
|
+
WARN: 2,
|
|
19
|
+
ERROR: 3,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ── Config access with 30-second TTL cache ────────────────────────────
|
|
23
|
+
|
|
24
|
+
let cachedConfig: ReturnType<typeof loadPluginConfig> | null = null;
|
|
25
|
+
let configCacheTime = 0;
|
|
26
|
+
const CONFIG_TTL_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
function getConfig() {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
if (cachedConfig && (now - configCacheTime) < CONFIG_TTL_MS) return cachedConfig;
|
|
31
|
+
cachedConfig = loadPluginConfig();
|
|
32
|
+
configCacheTime = now;
|
|
33
|
+
return cachedConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getLogFilePath(): string {
|
|
37
|
+
return join(getConfig().logging.logDir, "plugin.log");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getMinLevel(): number {
|
|
41
|
+
return LEVEL_MAP[getConfig().logging.logLevel] ?? LEVEL_MAP.INFO;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isLogEnabled(): boolean {
|
|
45
|
+
return getConfig().logging.logEnabled;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Opencode client for dual-track logging ────────────────────────────
|
|
49
|
+
|
|
50
|
+
let opencodeClient: any = null;
|
|
51
|
+
|
|
52
|
+
export function setOpencodeClient(client: any): void {
|
|
53
|
+
opencodeClient = client;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Log file rotation (5 MB threshold) ────────────────────────────────
|
|
57
|
+
// NOTE: multi-window concurrent rotate is a known limitation — the last
|
|
58
|
+
// writer to rename wins; earlier writers will create a fresh file.
|
|
59
|
+
|
|
60
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
61
|
+
|
|
62
|
+
function rotateIfNeeded(logFile: string): void {
|
|
63
|
+
try {
|
|
64
|
+
const s = statSync(logFile);
|
|
65
|
+
if (s.size > MAX_LOG_SIZE) {
|
|
66
|
+
renameSync(logFile, logFile.replace(".log", ".old.log"));
|
|
67
|
+
}
|
|
68
|
+
} catch { /* file doesn't exist yet, first write */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Startup cleanup (delete logs older than 7 days) ───────────────────
|
|
72
|
+
|
|
73
|
+
const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
74
|
+
|
|
75
|
+
function cleanupOldLogs(): void {
|
|
76
|
+
const logDir = getConfig().logging.logDir;
|
|
77
|
+
try {
|
|
78
|
+
const files = readdirSync(logDir);
|
|
79
|
+
const cutoff = Date.now() - LOG_MAX_AGE_MS;
|
|
80
|
+
for (const f of files) {
|
|
81
|
+
if (!f.endsWith(".log") && !f.endsWith(".old.log")) continue;
|
|
82
|
+
const fp = join(logDir, f);
|
|
83
|
+
try {
|
|
84
|
+
const s = statSync(fp);
|
|
85
|
+
if (s.mtimeMs < cutoff) unlinkSync(fp);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Run cleanup once at module load
|
|
92
|
+
cleanupOldLogs();
|
|
93
|
+
|
|
94
|
+
// ── Core logging ──────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
let lastLogTime = Date.now();
|
|
97
|
+
|
|
98
|
+
function ensureLogDir(logDir: string): void {
|
|
99
|
+
if (!existsSync(logDir)) {
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(logDir, { recursive: true });
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeLog(level: string, message: string, fields?: Record<string, unknown>): void {
|
|
107
|
+
if (!isLogEnabled()) return;
|
|
108
|
+
const lvl = LEVEL_MAP[level] ?? 0;
|
|
109
|
+
if (lvl < getMinLevel()) return;
|
|
110
|
+
|
|
111
|
+
const cfg = getConfig();
|
|
112
|
+
const logFile = getLogFilePath();
|
|
113
|
+
ensureLogDir(cfg.logging.logDir);
|
|
114
|
+
|
|
115
|
+
const now = new Date();
|
|
116
|
+
const nowMs = now.getTime();
|
|
117
|
+
const delta = ((nowMs - lastLogTime) / 1000).toFixed(2);
|
|
118
|
+
lastLogTime = nowMs;
|
|
119
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
120
|
+
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
121
|
+
const parts = [`${level.padEnd(5)} ${ts} +${delta}s service=cerebro`];
|
|
122
|
+
if (fields) {
|
|
123
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
124
|
+
const val = typeof v === "string" ? v : JSON.stringify(v);
|
|
125
|
+
parts.push(`${k}=${val}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
parts.push(message);
|
|
129
|
+
|
|
130
|
+
// Track 1: file
|
|
131
|
+
try {
|
|
132
|
+
rotateIfNeeded(logFile);
|
|
133
|
+
appendFileSync(logFile, parts.join(" ") + "\n");
|
|
134
|
+
} catch {}
|
|
135
|
+
|
|
136
|
+
// Track 2: opencode client
|
|
137
|
+
try {
|
|
138
|
+
opencodeClient?.app?.log({
|
|
139
|
+
body: { service: "cerebro", level: level.toLowerCase(), message, extra: fields },
|
|
140
|
+
});
|
|
141
|
+
} catch { /* opencode client not available, skip */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export function logInfo(message: string, fields?: Record<string, unknown>): void {
|
|
147
|
+
writeLog("INFO", message, fields);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function logWarn(message: string, fields?: Record<string, unknown>): void {
|
|
151
|
+
writeLog("WARN", message, fields);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function logError(message: string, fields?: Record<string, unknown>): void {
|
|
155
|
+
writeLog("ERROR", message, fields);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function logDebug(message: string, fields?: Record<string, unknown>): void {
|
|
159
|
+
writeLog("DEBUG", message, fields);
|
|
160
|
+
}
|
package/src/updater.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { openSync, closeSync, unlinkSync, statSync, writeSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
7
|
+
import { loadPluginConfig } from "./config.js";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
// ── Version fetching ────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
async function getLatestVersion(): Promise<string | null> {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execFileAsync("npm", ["view", "@mingxy/cerebro", "version"], { timeout: 10000 });
|
|
17
|
+
return stdout.trim();
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Semantic version comparison ─────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function compareVersions(a: string, b: string): number {
|
|
24
|
+
const pa = a.replace(/^v/, "").split(".").map(Number);
|
|
25
|
+
const pb = b.replace(/^v/, "").split(".").map(Number);
|
|
26
|
+
for (let i = 0; i < 3; i++) {
|
|
27
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
|
|
28
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Install dir detection ───────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function getInstallDir(): string {
|
|
36
|
+
try {
|
|
37
|
+
const pkgPath = require.resolve("@mingxy/cerebro/package.json");
|
|
38
|
+
return dirname(pkgPath);
|
|
39
|
+
} catch {
|
|
40
|
+
return join(homedir(), ".cache", "opencode", "packages", "@mingxy", "cerebro");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Install update via npm pack + tar ───────────────────────────────
|
|
45
|
+
|
|
46
|
+
async function installUpdate(targetDir: string): Promise<boolean> {
|
|
47
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "cerebro-update-"));
|
|
48
|
+
try {
|
|
49
|
+
// 1. Download tgz
|
|
50
|
+
await execFileAsync("npm", ["pack", "@mingxy/cerebro@latest", "--pack-destination", tmpDir], { timeout: 60000 });
|
|
51
|
+
|
|
52
|
+
// 2. Find the tgz file
|
|
53
|
+
const files = readdirSync(tmpDir);
|
|
54
|
+
const tgz = files.find(f => f.endsWith(".tgz"));
|
|
55
|
+
if (!tgz) return false;
|
|
56
|
+
|
|
57
|
+
// 3. Extract to target dir (strip the "package/" prefix from tgz)
|
|
58
|
+
await execFileAsync("tar", ["-xzf", join(tmpDir, tgz), "-C", targetDir, "--strip-components=1", "--no-same-owner", "--no-same-permissions"], { timeout: 30000 });
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
} finally {
|
|
64
|
+
try { rmSync(tmpDir, { recursive: true }); } catch {}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── File lock with stale lock cleanup ───────────────────────────────
|
|
69
|
+
|
|
70
|
+
const LOCK_FILE = join(tmpdir(), "cerebro-update.lock");
|
|
71
|
+
const STALE_LOCK_MS = 5 * 60 * 1000; // 5 minutes
|
|
72
|
+
let lockFd: number | null = null;
|
|
73
|
+
|
|
74
|
+
function acquireLock(): boolean {
|
|
75
|
+
try {
|
|
76
|
+
// Clean up stale lock first
|
|
77
|
+
try {
|
|
78
|
+
const s = statSync(LOCK_FILE);
|
|
79
|
+
if (Date.now() - s.mtimeMs > STALE_LOCK_MS) {
|
|
80
|
+
unlinkSync(LOCK_FILE);
|
|
81
|
+
}
|
|
82
|
+
} catch { /* lock file doesn't exist, normal */ }
|
|
83
|
+
|
|
84
|
+
lockFd = openSync(LOCK_FILE, "wx");
|
|
85
|
+
writeSync(lockFd, String(process.pid));
|
|
86
|
+
return true;
|
|
87
|
+
} catch { return false; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function releaseLock(): void {
|
|
91
|
+
if (lockFd !== null) {
|
|
92
|
+
try { closeSync(lockFd); } catch {}
|
|
93
|
+
lockFd = null;
|
|
94
|
+
try { unlinkSync(LOCK_FILE); } catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Main entry ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export async function checkAndUpdate(tui: any, currentVersion: string): Promise<void> {
|
|
101
|
+
const config = loadPluginConfig();
|
|
102
|
+
if (!config.autoUpdate) return;
|
|
103
|
+
|
|
104
|
+
const latest = await getLatestVersion();
|
|
105
|
+
if (!latest) return;
|
|
106
|
+
|
|
107
|
+
if (compareVersions(latest, currentVersion) <= 0) return; // already up to date
|
|
108
|
+
|
|
109
|
+
if (!acquireLock()) return; // another process is updating
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const targetDir = getInstallDir();
|
|
113
|
+
const success = await installUpdate(targetDir);
|
|
114
|
+
|
|
115
|
+
if (success) {
|
|
116
|
+
try {
|
|
117
|
+
tui?.showToast?.({
|
|
118
|
+
body: { message: `Cerebro updated to v${latest} — restart opencode to apply`, variant: "info" }
|
|
119
|
+
});
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
releaseLock();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface ChildConfig {
|
|
16
|
+
port: number;
|
|
17
|
+
webDir: string;
|
|
18
|
+
apiUrl: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── MIME map ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const MIME_TYPES: Record<string, string> = {
|
|
24
|
+
".html": "text/html; charset=utf-8",
|
|
25
|
+
".js": "application/javascript; charset=utf-8",
|
|
26
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
27
|
+
".css": "text/css; charset=utf-8",
|
|
28
|
+
".json": "application/json; charset=utf-8",
|
|
29
|
+
".svg": "image/svg+xml",
|
|
30
|
+
".png": "image/png",
|
|
31
|
+
".jpg": "image/jpeg",
|
|
32
|
+
".jpeg": "image/jpeg",
|
|
33
|
+
".gif": "image/gif",
|
|
34
|
+
".ico": "image/x-icon",
|
|
35
|
+
".woff": "font/woff",
|
|
36
|
+
".woff2": "font/woff2",
|
|
37
|
+
".ttf": "font/ttf",
|
|
38
|
+
".eot": "application/vnd.ms-fontobject",
|
|
39
|
+
".webp": "image/webp",
|
|
40
|
+
".webmanifest": "application/manifest+json",
|
|
41
|
+
".map": "application/json",
|
|
42
|
+
".txt": "text/plain; charset=utf-8",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function getMimeType(ext: string): string {
|
|
46
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Safe path resolver (prevent traversal) ───────────────────────────────
|
|
50
|
+
|
|
51
|
+
function resolveSafe(baseDir: string, pathname: string): string | null {
|
|
52
|
+
const relative = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
53
|
+
const resolved = path.resolve(baseDir, relative || ".");
|
|
54
|
+
if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── File serving ─────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function serveFile(
|
|
63
|
+
res: http.ServerResponse,
|
|
64
|
+
filePath: string,
|
|
65
|
+
apiUrl: string,
|
|
66
|
+
): void {
|
|
67
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
68
|
+
const contentType = getMimeType(ext);
|
|
69
|
+
|
|
70
|
+
fs.readFile(filePath, (err, data) => {
|
|
71
|
+
if (err) {
|
|
72
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
73
|
+
res.end("Internal Server Error");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let body: Buffer | string = data;
|
|
78
|
+
|
|
79
|
+
// Config injection: replace placeholder in index.html
|
|
80
|
+
if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
|
|
81
|
+
body = data
|
|
82
|
+
.toString("utf-8")
|
|
83
|
+
.replace(
|
|
84
|
+
/window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/,
|
|
85
|
+
`window.__OMEM_API_URL__ = "${apiUrl}"`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
res.writeHead(200, {
|
|
90
|
+
"Content-Type": contentType,
|
|
91
|
+
"Cache-Control":
|
|
92
|
+
ext === ".html"
|
|
93
|
+
? "no-cache, no-store, must-revalidate"
|
|
94
|
+
: "public, max-age=86400",
|
|
95
|
+
});
|
|
96
|
+
res.end(body);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Server lifecycle ─────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
let server: http.Server | null = null;
|
|
103
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
104
|
+
let pidFilePath = "";
|
|
105
|
+
let heartbeatFilePath = "";
|
|
106
|
+
|
|
107
|
+
function cleanup(): void {
|
|
108
|
+
if (heartbeatTimer) {
|
|
109
|
+
clearInterval(heartbeatTimer);
|
|
110
|
+
heartbeatTimer = null;
|
|
111
|
+
}
|
|
112
|
+
if (server) {
|
|
113
|
+
server.closeAllConnections?.();
|
|
114
|
+
const forceTimer = setTimeout(() => {
|
|
115
|
+
try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}, 3000);
|
|
118
|
+
server.close(() => {
|
|
119
|
+
clearTimeout(forceTimer);
|
|
120
|
+
try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function startServer(config: ChildConfig): void {
|
|
130
|
+
const { port, webDir, apiUrl } = config;
|
|
131
|
+
|
|
132
|
+
pidFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.pid`);
|
|
133
|
+
heartbeatFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.heartbeat`);
|
|
134
|
+
|
|
135
|
+
const indexPath = path.join(webDir, "index.html");
|
|
136
|
+
|
|
137
|
+
// 写 PID 文件(子进程自己的 PID)
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(pidFilePath, String(process.pid));
|
|
140
|
+
} catch {
|
|
141
|
+
console.error("[cerebro:web-child] Failed to write PID file");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 初始 touch 心跳文件
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(heartbeatFilePath, "");
|
|
147
|
+
} catch {
|
|
148
|
+
console.error("[cerebro:web-child] Failed to create heartbeat file");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
server = http.createServer(
|
|
152
|
+
(req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
153
|
+
// ── /health 端点 ──
|
|
154
|
+
if (req.url === "/health" || req.url === "/health/") {
|
|
155
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
156
|
+
res.end(JSON.stringify({ status: "ok", service: "cerebro", port }));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Only handle GET / HEAD
|
|
161
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
162
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
163
|
+
res.end("Method Not Allowed");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Parse URL, strip query string
|
|
168
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
169
|
+
// new URL() already decodes percent-encoding; no double-decode
|
|
170
|
+
const pathname = url.pathname;
|
|
171
|
+
|
|
172
|
+
// Resolve safe file path
|
|
173
|
+
const safePath = resolveSafe(webDir, pathname);
|
|
174
|
+
|
|
175
|
+
if (!safePath) {
|
|
176
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
177
|
+
res.end("Forbidden");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Try to serve the file directly
|
|
182
|
+
fs.stat(safePath, (statErr, stats) => {
|
|
183
|
+
if (!statErr && stats.isFile()) {
|
|
184
|
+
serveFile(res, safePath, apiUrl);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// SPA fallback: serve index.html for non-file paths
|
|
189
|
+
fs.stat(indexPath, (idxErr, idxStats) => {
|
|
190
|
+
if (idxErr || !idxStats.isFile()) {
|
|
191
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
192
|
+
res.end("Not Found");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
serveFile(res, indexPath, apiUrl);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
202
|
+
console.error(`[cerebro:web-child] Server error: ${err.message}`);
|
|
203
|
+
process.send?.({ type: "error", message: err.message });
|
|
204
|
+
cleanup();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
server.listen(port, "127.0.0.1", () => {
|
|
208
|
+
console.log(
|
|
209
|
+
`[cerebro:web-child] Server listening on http://localhost:${port}`,
|
|
210
|
+
);
|
|
211
|
+
process.send?.({ type: "ready", port });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── 心跳检测:每 30 秒检查心跳文件 mtime ──
|
|
215
|
+
heartbeatTimer = setInterval(() => {
|
|
216
|
+
try {
|
|
217
|
+
const stat = fs.statSync(heartbeatFilePath);
|
|
218
|
+
if (Date.now() - stat.mtimeMs > 60_000) {
|
|
219
|
+
console.log(
|
|
220
|
+
"[cerebro:web-child] Heartbeat expired, shutting down",
|
|
221
|
+
);
|
|
222
|
+
cleanup();
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// 心跳文件不存在,可能被清理,继续等待不主动退出
|
|
226
|
+
}
|
|
227
|
+
}, 30_000);
|
|
228
|
+
|
|
229
|
+
// 确保定时器不阻止进程退出
|
|
230
|
+
if (heartbeatTimer) heartbeatTimer.unref();
|
|
231
|
+
|
|
232
|
+
// ── 信号处理 ──
|
|
233
|
+
process.on("SIGTERM", cleanup);
|
|
234
|
+
process.on("SIGINT", cleanup);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── IPC 监听(只处理第一条消息) ─────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
let started = false;
|
|
240
|
+
|
|
241
|
+
process.on("message", (config: ChildConfig) => {
|
|
242
|
+
if (started) return;
|
|
243
|
+
started = true;
|
|
244
|
+
startServer(config);
|
|
245
|
+
});
|
package/src/web-server.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
|
|
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";
|
|
5
13
|
|
|
6
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -13,170 +21,185 @@ export interface WebServerConfig {
|
|
|
13
21
|
port?: number;
|
|
14
22
|
}
|
|
15
23
|
|
|
16
|
-
|
|
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";
|
|
24
|
+
export interface WebServerHandle {
|
|
25
|
+
address(): { port: number; family: string; address: string } | string | null;
|
|
42
26
|
}
|
|
43
27
|
|
|
44
|
-
// ──
|
|
28
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
45
29
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
30
|
+
/** Touch a file — update mtime or create if missing */
|
|
31
|
+
function touchFile(filePath: string): void {
|
|
32
|
+
try {
|
|
33
|
+
fs.utimesSync(filePath, new Date(), new Date());
|
|
34
|
+
} catch {
|
|
35
|
+
try {
|
|
36
|
+
fs.writeFileSync(filePath, "");
|
|
37
|
+
} catch { /* ignore */ }
|
|
52
38
|
}
|
|
53
|
-
return resolved;
|
|
54
39
|
}
|
|
55
40
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
41
|
+
/** Check if a process with the given PID is still running */
|
|
42
|
+
function isProcessAlive(pid: number): boolean {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
61
50
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
51
|
+
/** Probe an existing server's /health endpoint */
|
|
52
|
+
async function probeExistingServer(port: number): Promise<boolean> {
|
|
53
|
+
try {
|
|
54
|
+
const resp = await fetch(`http://127.0.0.1:${port}/health`);
|
|
55
|
+
if (resp.ok) {
|
|
56
|
+
const body = await resp.text();
|
|
57
|
+
return body.includes("cerebro");
|
|
67
58
|
}
|
|
59
|
+
} catch { /* connection refused → port free */ }
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Start / Stop ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export async function startWebServer(
|
|
66
|
+
config: WebServerConfig,
|
|
67
|
+
): Promise<WebServerHandle | null> {
|
|
68
|
+
const port =
|
|
69
|
+
config.port || parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || 5212;
|
|
70
|
+
const pidFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.pid`);
|
|
71
|
+
const heartbeatFilePath = path.join(
|
|
72
|
+
os.tmpdir(),
|
|
73
|
+
`cerebro-web-${port}.heartbeat`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// ── Step 1: 检查端口是否已有 cerebro server ──
|
|
77
|
+
if (await probeExistingServer(port)) {
|
|
78
|
+
console.log(`[cerebro:web] Reusing existing server on port ${port}`);
|
|
79
|
+
return createHandle(port, heartbeatFilePath);
|
|
80
|
+
}
|
|
68
81
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
// ── Step 2: 检查 PID 文件(可能有其他进程正在 fork) ──
|
|
83
|
+
try {
|
|
84
|
+
const pidStr = fs.readFileSync(pidFilePath, "utf-8").trim();
|
|
85
|
+
const pid = parseInt(pidStr, 10);
|
|
86
|
+
if (pid > 0 && isProcessAlive(pid)) {
|
|
87
|
+
// 有其他进程正在 fork 或运行,等待 200ms 后重试
|
|
88
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
89
|
+
if (await probeExistingServer(port)) {
|
|
90
|
+
console.log(
|
|
91
|
+
`[cerebro:web] Reusing server after PID check on port ${port}`,
|
|
92
|
+
);
|
|
93
|
+
return createHandle(port, heartbeatFilePath);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// 进程已死亡,清理 PID 文件
|
|
97
|
+
try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
|
|
74
98
|
}
|
|
99
|
+
} catch { /* PID 文件不存在,继续 */ }
|
|
75
100
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
},
|
|
101
|
+
// ── Step 3: 检查 web 目录 ──
|
|
102
|
+
const webDir = path.resolve(__dirname, "..", "web");
|
|
103
|
+
if (!fs.existsSync(webDir)) {
|
|
104
|
+
console.warn(
|
|
105
|
+
`[cerebro:web] Web directory not found: ${webDir}, skipping server start`,
|
|
118
106
|
);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (!fs.existsSync(path.join(webDir, "index.html"))) {
|
|
110
|
+
console.warn(
|
|
111
|
+
`[cerebro:web] index.html not found in ${webDir}, skipping server start`,
|
|
112
|
+
);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
119
115
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
console.warn(`[cerebro:web] Server error: ${err.message}`);
|
|
125
|
-
}
|
|
126
|
-
resolve(null);
|
|
127
|
-
});
|
|
116
|
+
// ── Step 4: 写 PID 文件(标记正在 fork) ──
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(pidFilePath, String(process.pid));
|
|
119
|
+
} catch { /* ignore */ }
|
|
128
120
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
121
|
+
// ── Step 5: Fork 子进程 ──
|
|
122
|
+
const childPath = path.resolve(__dirname, "web-server-child.js");
|
|
123
|
+
|
|
124
|
+
const child = fork(childPath, [], {
|
|
125
|
+
detached: true,
|
|
126
|
+
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
135
127
|
});
|
|
136
|
-
}
|
|
137
128
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
}
|
|
129
|
+
// Drain stdout/stderr to prevent pipe buffer from blocking the child
|
|
130
|
+
child.stdout?.on("data", () => {});
|
|
131
|
+
child.stderr?.on("data", () => {});
|
|
154
132
|
|
|
155
|
-
|
|
133
|
+
child.unref();
|
|
156
134
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
135
|
+
// 发送配置给子进程
|
|
136
|
+
child.send({ port, webDir, apiUrl: config.apiUrl });
|
|
137
|
+
|
|
138
|
+
// ── Step 6: 等待 ready 或超时 5s ──
|
|
139
|
+
const ready = await new Promise<boolean>((resolve) => {
|
|
140
|
+
const timeout = setTimeout(() => resolve(false), 5000);
|
|
161
141
|
|
|
162
|
-
|
|
163
|
-
"
|
|
164
|
-
|
|
142
|
+
child.on("message", (msg: { type: string }) => {
|
|
143
|
+
if (msg.type === "ready") {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
resolve(true);
|
|
146
|
+
} else if (msg.type === "error") {
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
resolve(false);
|
|
149
|
+
}
|
|
165
150
|
});
|
|
166
|
-
res.end(body);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
151
|
|
|
170
|
-
|
|
152
|
+
child.on("error", () => {
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
resolve(false);
|
|
155
|
+
});
|
|
171
156
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const timer = setTimeout(resolve, 3000);
|
|
176
|
-
server.close(() => {
|
|
177
|
-
clearTimeout(timer);
|
|
178
|
-
console.log("[cerebro:web] Server stopped");
|
|
179
|
-
resolve();
|
|
157
|
+
child.on("exit", () => {
|
|
158
|
+
clearTimeout(timeout);
|
|
159
|
+
resolve(false);
|
|
180
160
|
});
|
|
181
161
|
});
|
|
162
|
+
|
|
163
|
+
if (!ready) {
|
|
164
|
+
console.warn(
|
|
165
|
+
`[cerebro:web] Failed to start web server child process on port ${port}`,
|
|
166
|
+
);
|
|
167
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
168
|
+
try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(
|
|
173
|
+
`[cerebro:web] Web server child process started on port ${port}`,
|
|
174
|
+
);
|
|
175
|
+
return createHandle(port, heartbeatFilePath);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Create a handle with address() compat + heartbeat keep-alive */
|
|
179
|
+
function createHandle(
|
|
180
|
+
port: number,
|
|
181
|
+
heartbeatFilePath: string,
|
|
182
|
+
): WebServerHandle {
|
|
183
|
+
// 初始 touch
|
|
184
|
+
touchFile(heartbeatFilePath);
|
|
185
|
+
|
|
186
|
+
// 每 30 秒 touch 心跳文件,保持子进程存活
|
|
187
|
+
const timer = setInterval(() => {
|
|
188
|
+
touchFile(heartbeatFilePath);
|
|
189
|
+
}, 30_000);
|
|
190
|
+
|
|
191
|
+
// 确保定时器不阻止进程退出
|
|
192
|
+
timer.unref();
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
address() {
|
|
196
|
+
return { port, family: "IPv4", address: "127.0.0.1" };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function stopWebServer(_handle: WebServerHandle): Promise<void> {
|
|
202
|
+
// Intentionally do NOT touch heartbeat: parent exits → timer stops →
|
|
203
|
+
// heartbeat ages out → child detects mtime > 60s → self-terminate.
|
|
204
|
+
return Promise.resolve();
|
|
182
205
|
}
|