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; }; 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; hasReadWriteAccess: (candidatePath: string) => boolean; }; export type NodecgProcessManager = { startNodecgProcess: () => Promise; waitForNodecgReady: (startTime: number) => Promise; stopNodecgProcessGracefully: () => Promise; 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 | null = null; let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; let lastStderrLine: string | null = null; const startNodecgProcess = async (): Promise => { 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 => { 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 => { 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 { 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 { 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 { return new Promise((resolve) => { setTimer(resolve, ms); }); } function killNodecgProcessTree( pid: number, signal: NodeJS.Signals, log: (...args: unknown[]) => void, deps: Pick, ): 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); } } }