mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +00:00
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { spawn, SpawnOptions } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import net from "node:net";
|
|
import path from "node:path";
|
|
|
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
|
import { NODE_RUNTIME_NAME } from "../constants";
|
|
import { killProcessTree } from "./process-killer";
|
|
|
|
type NodecgProcessManagerConfig = {
|
|
isDev: boolean;
|
|
nodecgRootPath: string;
|
|
nodecgBaseUrl: string;
|
|
appConfig: AppRuntimeConfig;
|
|
log: (...args: unknown[]) => void;
|
|
deps?: Partial<NodecgProcessManagerDeps>;
|
|
};
|
|
|
|
type NodecgProcessManagerDeps = {
|
|
spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
|
|
pathExists: (candidatePath: string) => boolean;
|
|
fetchUrl: typeof fetch;
|
|
platform: NodeJS.Platform;
|
|
execPath: string;
|
|
env: NodeJS.ProcessEnv;
|
|
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
|
setTimer: (handler: () => void, timeoutMs: number) => unknown;
|
|
stdoutWrite: (chunk: string) => void;
|
|
stderrWrite: (chunk: string) => void;
|
|
probePortAvailable: (port: number) => Promise<boolean>;
|
|
hasReadWriteAccess: (candidatePath: string) => boolean;
|
|
};
|
|
|
|
type NodecgChildProcess = {
|
|
pid?: number;
|
|
killed: boolean;
|
|
exitCode: number | null;
|
|
signalCode: NodeJS.Signals | null;
|
|
stdout?: ProcessOutputStream | null;
|
|
stderr?: ProcessOutputStream | null;
|
|
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
on(event: "error", listener: (error: Error) => void): unknown;
|
|
once(event: "exit", listener: () => void): unknown;
|
|
};
|
|
|
|
type ProcessOutputStream = {
|
|
on(event: "data", listener: (chunk: unknown) => void): unknown;
|
|
};
|
|
|
|
export type NodecgProcessManager = {
|
|
startNodecgProcess: () => Promise<void>;
|
|
waitForNodecgReady: (startTime: number) => Promise<void>;
|
|
stopNodecgProcessGracefully: () => Promise<void>;
|
|
getState: () => NodecgProcessState;
|
|
};
|
|
|
|
type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
|
|
|
|
export function createNodecgProcessManager({
|
|
isDev,
|
|
nodecgRootPath,
|
|
nodecgBaseUrl,
|
|
appConfig,
|
|
log,
|
|
deps,
|
|
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
|
const resolvedDeps = resolveDeps(deps);
|
|
|
|
let nodecgProcess: NodecgChildProcess | null = null;
|
|
let nodecgState: NodecgProcessState = "idle";
|
|
let startNodecgPromise: Promise<void> | null = null;
|
|
let stopNodecgPromise: Promise<void> | null = null;
|
|
let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
|
let lastStderrLine: string | null = null;
|
|
|
|
const startNodecgProcess = (): Promise<void> => {
|
|
if (nodecgProcess && nodecgState === "running") {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (startNodecgPromise) {
|
|
return startNodecgPromise;
|
|
}
|
|
|
|
if (nodecgState === "stopping") {
|
|
return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
|
|
}
|
|
|
|
nodecgState = "starting";
|
|
startNodecgPromise = (async () => {
|
|
// Fail fast with actionable errors before spawning child processes.
|
|
validateNodecgInstall(
|
|
nodecgRootPath,
|
|
appConfig.bundleName,
|
|
resolvedDeps.pathExists,
|
|
resolvedDeps.hasReadWriteAccess,
|
|
);
|
|
|
|
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
|
|
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
|
|
if (!isPortAvailable) {
|
|
throw new Error(
|
|
`Port ${appConfig.nodecgPort} is already in use. Stop the process using it or set NODECG_PORT before starting.`,
|
|
);
|
|
}
|
|
|
|
const indexPath = path.join(nodecgRootPath, "index.js");
|
|
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
|
cwd: nodecgRootPath,
|
|
env: {
|
|
...resolvedDeps.env,
|
|
NODE_ENV: isDev ? "development" : "production",
|
|
NODECG_PORT: appConfig.nodecgPort,
|
|
ELECTRON_RUN_AS_NODE: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
detached: resolvedDeps.platform !== "win32",
|
|
shell: false,
|
|
windowsHide: true,
|
|
});
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
resolvedDeps.stdoutWrite(String(chunk));
|
|
});
|
|
|
|
child.stderr?.on("data", (chunk) => {
|
|
const line = String(chunk);
|
|
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
|
|
resolvedDeps.stderrWrite(line);
|
|
});
|
|
|
|
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
|
|
|
|
child.on("exit", (code, signal) => {
|
|
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
|
lastExit = { code, signal };
|
|
|
|
if (nodecgProcess === child) {
|
|
nodecgProcess = null;
|
|
}
|
|
|
|
if (nodecgState !== "stopping") {
|
|
nodecgState = code === 0 ? "stopped" : "failed";
|
|
}
|
|
});
|
|
|
|
lastExit = null;
|
|
lastStderrLine = null;
|
|
nodecgProcess = child;
|
|
nodecgState = "running";
|
|
})()
|
|
.catch((error: unknown) => {
|
|
nodecgState = "failed";
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
startNodecgPromise = null;
|
|
});
|
|
|
|
return startNodecgPromise;
|
|
};
|
|
|
|
const waitForNodecgReady = async (startTime: number): Promise<void> => {
|
|
// Poll the local NodeCG URL until it answers or we hit the configured timeout.
|
|
while (Date.now() - startTime < appConfig.startupTimeoutMs) {
|
|
if (!nodecgProcess) {
|
|
const exitDetails = lastExit
|
|
? `Last recorded exit: code=${lastExit.code ?? "null"}, signal=${lastExit.signal ?? "none"}.`
|
|
: "No NodeCG process exit code was recorded.";
|
|
const stderrDetails = lastStderrLine ? `Last stderr: ${lastStderrLine}` : "No stderr output captured.";
|
|
throw new Error(
|
|
[
|
|
"NodeCG exited before becoming ready.",
|
|
exitDetails,
|
|
stderrDetails,
|
|
`NodeCG path: ${nodecgRootPath}`,
|
|
"Check that the packaged runtime was installed correctly and the bundle exists.",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
try {
|
|
const response = await resolvedDeps.fetchUrl(nodecgBaseUrl, { method: "GET" });
|
|
if (response.ok || response.status === 404) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// retry until timeout
|
|
}
|
|
|
|
await sleep(500, resolvedDeps.setTimer);
|
|
}
|
|
|
|
throw new Error(`Timeout waiting for NodeCG at ${nodecgBaseUrl} (${appConfig.startupTimeoutMs}ms).`);
|
|
};
|
|
|
|
const stopNodecgProcessGracefully = (): Promise<void> => {
|
|
// Reuse the same stop promise to avoid sending multiple kill signals during app shutdown.
|
|
if (stopNodecgPromise) {
|
|
return stopNodecgPromise;
|
|
}
|
|
|
|
if (!nodecgProcess || nodecgProcess.killed) {
|
|
nodecgState = "stopped";
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const processToStop = nodecgProcess;
|
|
const pid = processToStop.pid;
|
|
|
|
if (typeof pid !== "number") {
|
|
log("NodeCG pid unavailable, skipping graceful stop");
|
|
nodecgProcess = null;
|
|
nodecgState = "stopped";
|
|
return Promise.resolve();
|
|
}
|
|
|
|
nodecgState = "stopping";
|
|
log(`Stopping NodeCG pid=${pid}`);
|
|
killProcessTree(pid, "SIGTERM", {
|
|
platform: resolvedDeps.platform,
|
|
spawnProcess: resolvedDeps.spawnProcess,
|
|
killProcess: resolvedDeps.killProcess,
|
|
log,
|
|
});
|
|
|
|
stopNodecgPromise = new Promise((resolve) => {
|
|
let completed = false;
|
|
|
|
const complete = () => {
|
|
if (completed) {
|
|
return;
|
|
}
|
|
|
|
completed = true;
|
|
|
|
if (nodecgProcess === processToStop) {
|
|
nodecgProcess = null;
|
|
}
|
|
|
|
nodecgState = "stopped";
|
|
stopNodecgPromise = null;
|
|
resolve();
|
|
};
|
|
|
|
processToStop.once("exit", () => {
|
|
complete();
|
|
});
|
|
|
|
resolvedDeps.setTimer(
|
|
() => {
|
|
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
|
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
|
killProcessTree(pid, "SIGKILL", {
|
|
platform: resolvedDeps.platform,
|
|
spawnProcess: resolvedDeps.spawnProcess,
|
|
killProcess: resolvedDeps.killProcess,
|
|
log,
|
|
});
|
|
complete();
|
|
}
|
|
},
|
|
Math.max(0, appConfig.nodecgKillTimeoutMs),
|
|
);
|
|
});
|
|
|
|
return stopNodecgPromise;
|
|
};
|
|
|
|
return {
|
|
startNodecgProcess,
|
|
waitForNodecgReady,
|
|
stopNodecgProcessGracefully,
|
|
getState: () => nodecgState,
|
|
};
|
|
}
|
|
|
|
function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessManagerDeps {
|
|
return {
|
|
spawnProcess: deps?.spawnProcess ?? spawn,
|
|
pathExists: deps?.pathExists ?? fs.existsSync,
|
|
fetchUrl: deps?.fetchUrl ?? fetch,
|
|
platform: deps?.platform ?? process.platform,
|
|
execPath: deps?.execPath ?? process.execPath,
|
|
env: deps?.env ?? process.env,
|
|
killProcess: deps?.killProcess ?? process.kill,
|
|
setTimer: deps?.setTimer ?? setTimeout,
|
|
stdoutWrite: deps?.stdoutWrite ?? ((chunk) => process.stdout.write(chunk)),
|
|
stderrWrite: deps?.stderrWrite ?? ((chunk) => process.stderr.write(chunk)),
|
|
probePortAvailable: deps?.probePortAvailable ?? probePortAvailable,
|
|
hasReadWriteAccess: deps?.hasReadWriteAccess ?? hasReadWriteAccess,
|
|
};
|
|
}
|
|
|
|
function validateNodecgInstall(
|
|
nodecgRootPath: string,
|
|
bundleName: string,
|
|
pathExists: (candidatePath: string) => boolean,
|
|
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
|
|
): void {
|
|
const indexPath = path.join(nodecgRootPath, "index.js");
|
|
const nodecgBootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
|
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
|
|
|
if (!pathExists(nodecgRootPath)) {
|
|
throw new Error(`NodeCG folder does not exist: ${nodecgRootPath}`);
|
|
}
|
|
|
|
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
|
|
throw new Error(`No read/write permissions on NodeCG: ${nodecgRootPath}`);
|
|
}
|
|
|
|
if (!pathExists(indexPath)) {
|
|
throw new Error(`${indexPath} was not found. Build the packaged NodeCG runtime before starting Electron.`);
|
|
}
|
|
|
|
if (!pathExists(nodecgBootstrapPath)) {
|
|
throw new Error(
|
|
[
|
|
"NodeCG is present but internal dependencies are missing.",
|
|
`Not found: ${nodecgBootstrapPath}`,
|
|
"Solution: rebuild the packaged runtime:",
|
|
" npm run prepare:runtime",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
if (!pathExists(bundlePath)) {
|
|
throw new Error(
|
|
[
|
|
`Bundle '${bundleName}' was not found.`,
|
|
`Expected path: ${bundlePath}`,
|
|
"Build and package the Scoreko bundle before running Electron.",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
}
|
|
|
|
function hasReadWriteAccess(candidatePath: string): boolean {
|
|
try {
|
|
fs.accessSync(candidatePath, fs.constants.R_OK | fs.constants.W_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function probePortAvailable(port: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
// A successful TCP connection means some process is already listening on the port.
|
|
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
let resolved = false;
|
|
|
|
const complete = (isAvailable: boolean): void => {
|
|
if (resolved) {
|
|
return;
|
|
}
|
|
|
|
resolved = true;
|
|
socket.destroy();
|
|
resolve(isAvailable);
|
|
};
|
|
|
|
socket.setTimeout(1000);
|
|
|
|
socket.once("connect", () => {
|
|
complete(false);
|
|
});
|
|
|
|
socket.once("timeout", () => {
|
|
complete(true);
|
|
});
|
|
|
|
socket.once("error", (error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ECONNREFUSED" || error.code === "EHOSTUNREACH") {
|
|
complete(true);
|
|
return;
|
|
}
|
|
|
|
complete(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimer(resolve, ms);
|
|
});
|
|
}
|