refactor(main): extraer configuración, ventanas, procesos y errores

This commit is contained in:
Pandipipas
2026-02-21 18:30:07 +01:00
parent 4eb639a6c5
commit e4e3ea4459
6 changed files with 426 additions and 346 deletions
+209
View File
@@ -0,0 +1,209 @@
import { ChildProcess, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config";
import { NODE_RUNTIME_NAME } from "../constants";
type NodecgProcessManagerConfig = {
isDev: boolean;
nodecgPath: string;
baseUrl: string;
runtimeConfig: AppRuntimeConfig;
log: (...args: unknown[]) => void;
};
export type NodecgProcessManager = {
startNodeCG: () => ChildProcess;
waitForNodeCGReady: (startTime: number) => Promise<void>;
stopNodeCG: () => Promise<void>;
getProcess: () => ChildProcess | null;
};
export function createNodecgProcessManager({
isDev,
nodecgPath,
baseUrl,
runtimeConfig,
log,
}: NodecgProcessManagerConfig): NodecgProcessManager {
let nodecgProcess: ChildProcess | null = null;
let stopNodeCGPromise: Promise<void> | null = null;
const startNodeCG = (): ChildProcess => {
validateNodeCGInstall(nodecgPath, runtimeConfig.bundleName);
const indexPath = path.join(nodecgPath, "index.js");
const child = spawn(process.execPath, [indexPath], {
cwd: nodecgPath,
env: {
...process.env,
NODE_ENV: isDev ? "development" : "production",
NODECG_PORT: runtimeConfig.nodecgPort,
ELECTRON_RUN_AS_NODE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
shell: process.platform === "win32",
});
child.stdout?.on("data", (chunk) => {
process.stdout.write(String(chunk));
});
child.stderr?.on("data", (chunk) => {
process.stderr.write(String(chunk));
});
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
child.on("exit", (code, signal) => {
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
nodecgProcess = null;
});
nodecgProcess = child;
return child;
};
const waitForNodeCGReady = async (startTime: number): Promise<void> => {
while (Date.now() - startTime < runtimeConfig.startupTimeoutMs) {
if (!nodecgProcess) {
throw new Error("NodeCG terminó antes de estar listo.");
}
try {
const response = await fetch(baseUrl, { method: "GET" });
if (response.ok || response.status === 404) {
return;
}
} catch {
// retry until timeout
}
await sleep(500);
}
throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${runtimeConfig.startupTimeoutMs}ms).`);
};
const stopNodeCG = (): Promise<void> => {
if (stopNodeCGPromise) {
return stopNodeCGPromise;
}
if (!nodecgProcess || nodecgProcess.killed) {
return Promise.resolve();
}
const processToStop = nodecgProcess;
const pid = processToStop.pid;
if (typeof pid !== "number") {
log("NodeCG pid unavailable, skipping graceful stop");
return Promise.resolve();
}
log(`Stopping NodeCG pid=${pid}`);
killNodeCGProcessTree(pid, "SIGTERM", log);
stopNodeCGPromise = new Promise((resolve) => {
const complete = () => {
if (nodecgProcess === processToStop) {
nodecgProcess = null;
}
stopNodeCGPromise = null;
resolve();
};
processToStop.once("exit", () => {
complete();
});
setTimeout(() => {
if (processToStop.exitCode === null && processToStop.signalCode === null) {
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
killNodeCGProcessTree(pid, "SIGKILL", log);
}
}, Math.max(0, runtimeConfig.nodecgKillTimeoutMs));
});
return stopNodeCGPromise;
};
return {
startNodeCG,
waitForNodeCGReady,
stopNodeCG,
getProcess: () => nodecgProcess,
};
}
function validateNodeCGInstall(nodecgPath: string, bundleName: string): void {
const indexPath = path.join(nodecgPath, "index.js");
const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const bundlePath = path.join(nodecgPath, "bundles", bundleName);
if (!fs.existsSync(nodecgPath)) {
throw new Error(`No existe la carpeta NodeCG: ${nodecgPath}`);
}
if (!fs.existsSync(indexPath)) {
throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`);
}
if (!fs.existsSync(nodecgBootstrapPath)) {
throw new Error(
[
"NodeCG está presente pero faltan dependencias internas.",
`No existe: ${nodecgBootstrapPath}`,
"Solución: entra a lib/nodecg e instala dependencias:",
" npm install",
].join("\n"),
);
}
if (!fs.existsSync(bundlePath)) {
throw new Error(
[
`No se encontró el bundle '${bundleName}'.`,
`Ruta esperada: ${bundlePath}`,
"Copia/clona tu bundle dentro de lib/nodecg/bundles antes de ejecutar Electron.",
].join("\n"),
);
}
}
function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals, log: (...args: unknown[]) => void): boolean {
if (process.platform === "win32") {
const force = signal === "SIGKILL" ? "/F" : "";
const killer = spawn("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
stdio: "ignore",
shell: true,
});
killer.on("error", (error) => {
log(`taskkill error for pid=${pid}`, error);
});
return true;
}
try {
process.kill(-pid, signal);
return true;
} catch {
try {
process.kill(pid, signal);
return true;
} catch {
return false;
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}