mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
311 lines
9.5 KiB
TypeScript
311 lines
9.5 KiB
TypeScript
import { ChildProcess, 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";
|
|
|
|
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) => ChildProcess;
|
|
pathExists: (candidatePath: string) => boolean;
|
|
fetchUrl: typeof fetch;
|
|
platform: NodeJS.Platform;
|
|
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;
|
|
};
|
|
|
|
export type NodecgProcessManager = {
|
|
startNodecgProcess: () => Promise<ChildProcess>;
|
|
waitForNodecgReady: (startTime: number) => Promise<void>;
|
|
stopNodecgProcessGracefully: () => Promise<void>;
|
|
getProcess: () => ChildProcess | null;
|
|
};
|
|
|
|
export function createNodecgProcessManager({
|
|
isDev,
|
|
nodecgRootPath,
|
|
nodecgBaseUrl,
|
|
appConfig,
|
|
log,
|
|
deps,
|
|
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
|
const resolvedDeps = resolveDeps(deps);
|
|
|
|
let nodecgProcess: ChildProcess | 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 = async (): Promise<ChildProcess> => {
|
|
validateNodecgInstall(nodecgRootPath, resolvedDeps.platform, 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 command = resolvedDeps.platform === "win32" ? "npx.cmd" : "npx";
|
|
const child = resolvedDeps.spawnProcess(command, ["nodecg", "start"], {
|
|
cwd: nodecgRootPath,
|
|
env: {
|
|
...resolvedDeps.env,
|
|
NODE_ENV: isDev ? "development" : "production",
|
|
NODECG_PORT: appConfig.nodecgPort,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
detached: resolvedDeps.platform !== "win32",
|
|
shell: false,
|
|
});
|
|
|
|
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 };
|
|
nodecgProcess = null;
|
|
});
|
|
|
|
lastExit = null;
|
|
lastStderrLine = null;
|
|
nodecgProcess = child;
|
|
return child;
|
|
};
|
|
|
|
const waitForNodecgReady = async (startTime: number): Promise<void> => {
|
|
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 lib/scoreko-dev dependencies are installed 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> => {
|
|
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, resolvedDeps);
|
|
|
|
stopNodecgPromise = new Promise((resolve) => {
|
|
const complete = () => {
|
|
if (nodecgProcess === processToStop) {
|
|
nodecgProcess = null;
|
|
}
|
|
|
|
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}`);
|
|
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
|
|
}
|
|
},
|
|
Math.max(0, appConfig.nodecgKillTimeoutMs),
|
|
);
|
|
});
|
|
|
|
return stopNodecgPromise;
|
|
};
|
|
|
|
return {
|
|
startNodecgProcess,
|
|
waitForNodecgReady,
|
|
stopNodecgProcessGracefully,
|
|
getProcess: () => nodecgProcess,
|
|
};
|
|
}
|
|
|
|
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,
|
|
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,
|
|
platform: NodeJS.Platform,
|
|
pathExists: (candidatePath: string) => boolean,
|
|
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
|
|
): void {
|
|
const packageJsonPath = path.join(nodecgRootPath, "package.json");
|
|
const nodecgDependencyPath = path.join(nodecgRootPath, "node_modules", "nodecg", "package.json");
|
|
const nodecgCliPath = path.join(nodecgRootPath, "node_modules", ".bin", platform === "win32" ? "nodecg.cmd" : "nodecg");
|
|
const bundleAssetDirs = ["dashboard", "graphics", "extension", "extensions"].map((dir) =>
|
|
path.join(nodecgRootPath, dir),
|
|
);
|
|
|
|
if (!pathExists(nodecgRootPath)) {
|
|
throw new Error(`Scoreko app folder does not exist: ${nodecgRootPath}`);
|
|
}
|
|
|
|
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
|
|
throw new Error(`No read/write permissions on scoreko app folder: ${nodecgRootPath}`);
|
|
}
|
|
|
|
if (!pathExists(packageJsonPath)) {
|
|
throw new Error(`${packageJsonPath} was not found. Expected a NodeCG bundle app at lib/scoreko-dev.`);
|
|
}
|
|
|
|
if (!pathExists(nodecgDependencyPath) || !pathExists(nodecgCliPath)) {
|
|
throw new Error(
|
|
[
|
|
"NodeCG dependency is missing in lib/scoreko-dev.",
|
|
`Not found: ${nodecgDependencyPath} and/or ${nodecgCliPath}`,
|
|
"Solution: enter lib/scoreko-dev and install dependencies:",
|
|
" npm install",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
if (!bundleAssetDirs.some((candidatePath) => pathExists(candidatePath))) {
|
|
throw new Error(
|
|
[
|
|
"scoreko-dev bundle appears incomplete.",
|
|
`Expected one of: ${bundleAssetDirs.join(", ")}`,
|
|
"Ensure extensions/dashboard/graphics assets exist inside lib/scoreko-dev.",
|
|
].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) => {
|
|
const server = net.createServer();
|
|
|
|
server.once("error", () => {
|
|
resolve(false);
|
|
});
|
|
|
|
server.once("listening", () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
|
|
server.listen(port, "127.0.0.1");
|
|
});
|
|
}
|
|
|
|
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimer(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function killNodecgProcessTree(
|
|
pid: number,
|
|
signal: NodeJS.Signals,
|
|
log: (...args: unknown[]) => void,
|
|
deps: Pick<NodecgProcessManagerDeps, "platform" | "killProcess">,
|
|
): void {
|
|
if (deps.platform === "win32") {
|
|
try {
|
|
deps.killProcess(pid, signal);
|
|
} catch (error) {
|
|
log(`Error sending ${signal} to pid=${pid}`, error);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
deps.killProcess(-pid, signal);
|
|
} catch {
|
|
try {
|
|
deps.killProcess(pid, signal);
|
|
} catch (error) {
|
|
log(`Error sending ${signal} to pid=${pid}`, error);
|
|
}
|
|
}
|
|
}
|