feat: separate frontend and backend services

This commit is contained in:
luckyyzh
2026-08-10 09:45:22 +08:00
parent cb9ec7a869
commit 7a9859e60d
132 changed files with 8393 additions and 6388 deletions
+131 -22
View File
@@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { platform } from "node:os";
import { join } from "node:path";
import { configurePiMemory } from "./configure-pi-memory.mjs";
import { configurePiAiVision } from "./configure-pi-ai-vision.mjs";
@@ -23,6 +24,7 @@ if (!npmCliPath) {
}
const childEnvironment = managedEnvironment();
const serverPort = Number(childEnvironment.PI_SERVER_PORT ?? "30142");
const memoryConfiguration = configurePiMemory({ quiet: true });
if (memoryConfiguration.status === "missing") {
console.warn("[memory] pi-memory is not installed; run npm run setup to enable managed memory");
@@ -38,28 +40,135 @@ if (await isLocalPortListening(30141)) {
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
}
const child = spawn(
process.execPath,
[npmCliPath, "--prefix", join(rootDir, "pi-web"), "run", target],
{
cwd: rootDir,
env: childEnvironment,
stdio: "inherit",
},
);
const children = [];
let shuttingDown = false;
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => child.kill(signal));
function spawnPackageScript(packageDir, script) {
return spawn(
process.execPath,
[npmCliPath, "--prefix", join(rootDir, packageDir), "run", script],
{
cwd: rootDir,
env: childEnvironment,
stdio: "inherit",
},
);
}
child.on("error", (error) => {
console.error(error);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (!signal && code === 0 && target === "build") {
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
function killChild(child, signal) {
if (child.killed || child.exitCode !== null) return;
if (platform() === "win32") {
// Windows has no process groups: killing the npm wrapper leaves the
// real workers (tsx watch / next dev) orphaned and holding their ports.
// taskkill /T terminates the whole tree instead.
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
} else {
child.kill(signal);
}
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 1);
});
}
function stopAll(signal) {
shuttingDown = true;
for (const child of children) {
killChild(child, signal);
}
}
// TCP listening alone is not enough: an orphaned backend from a previous run
// can hold the port. Verify identity through the health endpoint instead.
async function isBackendHealthy() {
try {
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, {
signal: AbortSignal.timeout(750),
});
if (!response.ok) return false;
const body = await response.json();
return body?.name === "pi-agent-server";
} catch {
return false;
}
}
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => stopAll(signal));
}
if (target === "build") {
// Sequential builds: the backend is independent, the frontend only needs
// the shared types package, so order matters only for readable logs.
const serverBuild = spawnPackageScript("server", "build");
serverBuild.on("error", (error) => {
console.error(error);
process.exit(1);
});
serverBuild.on("exit", (code, signal) => {
if (signal || code !== 0) process.exit(code ?? 1);
const webBuild = spawnPackageScript("pi-web", "build");
webBuild.on("error", (error) => {
console.error(error);
process.exit(1);
});
webBuild.on("exit", (code, signal) => {
if (!signal && code === 0) {
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
}
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 1);
});
});
} else {
// Backend first: agent sessions live in pi-agent-server, and Pi Web's
// /api rewrites need it listening before the first request arrives.
if (await isLocalPortListening(serverPort)) {
console.error(
`[run] port ${serverPort} is already in use (possibly an orphaned backend). ` +
`Find it with: netstat -ano | findstr :${serverPort}`,
);
process.exit(1);
}
const serverScript = target.startsWith("dev") ? "dev" : "start";
const server = spawnPackageScript("server", serverScript);
children.push(server);
server.on("error", (error) => {
console.error(error);
process.exit(1);
});
server.on("exit", (code, signal) => {
if (shuttingDown) return;
console.error(`[run] backend exited (${code ?? signal}); shutting down Pi Web`);
stopAll(signal ?? "SIGTERM");
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 1);
});
const startWeb = () => {
const web = spawnPackageScript("pi-web", target);
children.push(web);
web.on("error", (error) => {
console.error(error);
process.exit(1);
});
web.on("exit", (code, signal) => {
stopAll(signal ?? "SIGTERM");
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 1);
});
};
let webStarted = false;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (server.exitCode !== null || server.killed) break;
if (await isBackendHealthy()) {
webStarted = true;
startWeb();
break;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
}
if (!webStarted) {
console.error(`[run] backend did not become ready on port ${serverPort} within 30s`);
stopAll("SIGTERM");
process.exit(1);
}
}