refactor: run NodeCG from lib/scoreko-dev for Node 24 migration

This commit is contained in:
Pandipipas
2026-03-02 22:53:22 +01:00
parent 162b0685c6
commit eae612cb38
10 changed files with 126 additions and 140 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDire
const isDev = !app.isPackaged;
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
const nodecgRootPath = path.resolve(rootPath, "lib", "nodecg");
const nodecgRootPath = path.resolve(rootPath, "lib", "scoreko-dev");
const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
+59 -85
View File
@@ -20,7 +20,6 @@ type NodecgProcessManagerDeps = {
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;
@@ -53,7 +52,6 @@ export function createNodecgProcessManager({
let lastStderrLine: string | null = null;
const startNodecgProcess = async (): Promise<ChildProcess> => {
// Fail fast with actionable errors before spawning child processes.
validateNodecgInstall(
nodecgRootPath,
appConfig.bundleName,
@@ -69,18 +67,17 @@ export function createNodecgProcessManager({
);
}
const indexPath = path.join(nodecgRootPath, "index.js");
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
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,
ELECTRON_RUN_AS_NODE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: resolvedDeps.platform !== "win32",
shell: resolvedDeps.platform === "win32",
shell: false,
});
child.stdout?.on("data", (chunk) => {
@@ -108,7 +105,6 @@ export function createNodecgProcessManager({
};
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
@@ -121,7 +117,7 @@ export function createNodecgProcessManager({
exitDetails,
stderrDetails,
`NodeCG path: ${nodecgRootPath}`,
"Check that lib/nodecg dependencies are installed and the bundle exists.",
"Check that lib/scoreko-dev dependencies are installed and the bundle exists.",
].join("\n"),
);
}
@@ -142,7 +138,6 @@ export function createNodecgProcessManager({
};
const stopNodecgProcessGracefully = (): Promise<void> => {
// Reuse the same stop promise to avoid sending multiple kill signals during app shutdown.
if (stopNodecgPromise) {
return stopNodecgPromise;
}
@@ -204,7 +199,6 @@ function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessMan
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,
@@ -221,39 +215,46 @@ function validateNodecgInstall(
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);
const packageJsonPath = path.join(nodecgRootPath, "package.json");
const nodecgCliPath = path.join(
nodecgRootPath,
"node_modules",
".bin",
process.platform === "win32" ? "nodecg.cmd" : "nodecg",
);
const bundleAssetDirs = ["dashboard", "graphics", "extension", "extensions"].map((dir) =>
path.join(nodecgRootPath, dir),
);
if (!pathExists(nodecgRootPath)) {
throw new Error(`NodeCG folder does not exist: ${nodecgRootPath}`);
throw new Error(`Scoreko app folder does not exist: ${nodecgRootPath}`);
}
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
throw new Error(`No read/write permissions on NodeCG: ${nodecgRootPath}`);
throw new Error(`No read/write permissions on scoreko app folder: ${nodecgRootPath}`);
}
if (!pathExists(indexPath)) {
throw new Error(`${indexPath} was not found. Copy a full NodeCG installation into lib/nodecg.`);
if (!pathExists(packageJsonPath)) {
throw new Error(`${packageJsonPath} was not found. Expected a NodeCG bundle app at lib/scoreko-dev.`);
}
if (!pathExists(nodecgBootstrapPath)) {
if (!pathExists(nodecgCliPath)) {
throw new Error(
[
"NodeCG is present but internal dependencies are missing.",
`Not found: ${nodecgBootstrapPath}`,
"Solution: enter lib/nodecg and install dependencies:",
"NodeCG dependency is missing in lib/scoreko-dev.",
`Not found: ${nodecgCliPath}`,
"Solution: enter lib/scoreko-dev and install dependencies:",
" npm install",
].join("\n"),
);
}
if (!pathExists(bundlePath)) {
if (!bundleAssetDirs.some((candidatePath) => pathExists(candidatePath))) {
throw new Error(
[
`Bundle '${bundleName}' was not found.`,
`Expected path: ${bundlePath}`,
"Copy/clone your bundle inside lib/nodecg/bundles before running Electron.",
`Bundle '${bundleName}' appears incomplete.`,
`Expected one of: ${bundleAssetDirs.join(", ")}`,
"Ensure extensions/dashboard/graphics assets exist inside lib/scoreko-dev.",
].join("\n"),
);
}
@@ -270,76 +271,49 @@ function hasReadWriteAccess(candidatePath: string): boolean {
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 server = net.createServer();
const complete = (isAvailable: boolean): void => {
if (resolved) {
return;
}
resolved = true;
socket.destroy();
resolve(isAvailable);
};
socket.setTimeout(1000);
socket.once("connect", () => {
complete(false);
server.once("error", () => {
resolve(false);
});
socket.once("timeout", () => {
complete(true);
server.once("listening", () => {
server.close(() => resolve(true));
});
socket.once("error", (error: NodeJS.ErrnoException) => {
if (error.code === "ECONNREFUSED" || error.code === "EHOSTUNREACH") {
complete(true);
return;
}
complete(false);
});
server.listen(port, "127.0.0.1");
});
}
function killNodecgProcessTree(
pid: number,
signal: NodeJS.Signals,
log: (...args: unknown[]) => void,
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
): boolean {
if (deps.platform === "win32") {
const force = signal === "SIGKILL" ? "/F" : "";
const killer = deps.spawnProcess("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 {
deps.killProcess(-pid, signal);
return true;
} catch {
try {
deps.killProcess(pid, signal);
return true;
} catch {
return false;
}
}
}
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);
}
}
}
+11 -11
View File
@@ -35,7 +35,7 @@ function getBaseConfig(): AppRuntimeConfig {
test("startNodeCG validates NodeCG installation before starting", async () => {
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
@@ -49,13 +49,13 @@ test("startNodeCG validates NodeCG installation before starting", async () => {
await assert.rejects(async () => {
await manager.startNodecgProcess();
}, /NodeCG folder does not exist/);
}, /Scoreko app folder does not exist/);
});
test("startNodeCG fails when there are no read/write permissions", async () => {
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
@@ -67,14 +67,14 @@ test("startNodeCG fails when there are no read/write permissions", async () => {
await assert.rejects(async () => {
await manager.startNodecgProcess();
}, /No read\/write permissions on NodeCG/);
}, /No read\/write permissions on scoreko app folder/);
});
test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
const child = new MockChildProcess(4321);
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
@@ -107,7 +107,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
@@ -153,7 +153,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
@@ -186,7 +186,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: {
...getBaseConfig(),
@@ -222,7 +222,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
test("startNodeCG fails if the port is already in use", async () => {
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
@@ -242,7 +242,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
const child = new MockChildProcess(4242);
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
@@ -275,7 +275,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
assert.ok(error instanceof Error);
assert.match(error.message, /NodeCG exited before becoming ready/);
assert.match(error.message, /Last recorded exit/);
assert.match(error.message, /NodeCG path: \/fake\/nodecg/);
assert.match(error.message, /NodeCG path: \/fake\/scoreko-dev/);
return true;
},
);