feat: enhance NodeCG runtime management and packaging

- Update .gitignore and .prettierignore to exclude additional cache and configuration files.
- Revise README.md for clarity on build processes and runtime behavior.
- Improve architecture documentation to reflect changes in startup flow and module responsibilities.
- Modify troubleshooting guide to address common runtime issues and installation steps.
- Enhance ESLint configuration to ignore more directories.
- Update package.json scripts for better build and distribution processes.
- Introduce build-scoreko-bundle.mjs for building the Scoreko bundle.
- Implement prepare-nodecg-runtime.mjs for managing NodeCG runtime installation and updates.
- Add runtime-provisioner.ts to handle user-specific NodeCG runtime provisioning.
- Create tests for runtime provisioning to ensure correct behavior.
- Refactor process-manager.ts and main.ts to integrate new runtime management logic.
This commit is contained in:
2026-05-09 17:45:36 +02:00
parent b10b8adb98
commit 41e4e91c4b
16 changed files with 737 additions and 100 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
export const NODE_RUNTIME_NAME = "electron internal node";
export const NODE_RUNTIME_NAME = "Electron embedded Node.js";
export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
export const DEFAULT_WINDOW_SIZE = {
+22 -12
View File
@@ -3,7 +3,8 @@ import path from "node:path";
import { getRuntimeConfig } from "./config/runtime-config";
import { showFatalError, log } from "./errors/error-presenter";
import { createNodecgProcessManager } from "./nodecg/process-manager";
import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager";
import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner";
import { getRemainingDelayMs } from "./utils/timing";
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
@@ -15,7 +16,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 sourceNodecgRuntimePath = path.resolve(rootPath, "lib", "nodecg");
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}`;
@@ -26,18 +27,11 @@ if (!hasSingleInstanceLock) {
app.quit();
}
const nodecgManager = createNodecgProcessManager({
isDev,
nodecgRootPath,
nodecgBaseUrl,
appConfig,
log,
});
type AppShutdownState = "running" | "stopping" | "stopped";
let mainWindow: BrowserWindow | null = null;
let loadingWindow: BrowserWindow | null = null;
let nodecgManager: NodecgProcessManager | null = null;
let shutdownState: AppShutdownState = "running";
function focusExistingWindow(): void {
@@ -56,6 +50,22 @@ function focusExistingWindow(): void {
}
async function launchApplication(): Promise<void> {
const nodecgRootPath = prepareUserNodecgRuntime({
sourceRuntimePath: sourceNodecgRuntimePath,
userDataPath: app.getPath("userData"),
appVersion: app.getVersion(),
bundleName: appConfig.bundleName,
log,
});
nodecgManager = createNodecgProcessManager({
isDev,
nodecgRootPath,
nodecgBaseUrl,
appConfig,
log,
});
// We create both windows early so startup feels instant while NodeCG is booting in the background.
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
loadingWindow = createLoadingWindow({ appConfig, rootPath });
@@ -110,12 +120,12 @@ function stopNodecgGracefully(): Promise<void> {
}
if (shutdownState === "stopping") {
return nodecgManager.stopNodecgProcessGracefully();
return nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve();
}
shutdownState = "stopping";
return nodecgManager.stopNodecgProcessGracefully().finally(() => {
return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).finally(() => {
shutdownState = "stopped";
});
}
+5 -5
View File
@@ -121,7 +121,7 @@ export function createNodecgProcessManager({
exitDetails,
stderrDetails,
`NodeCG path: ${nodecgRootPath}`,
"Check that lib/nodecg dependencies are installed and the bundle exists.",
"Check that the packaged runtime was installed correctly and the bundle exists.",
].join("\n"),
);
}
@@ -234,7 +234,7 @@ function validateNodecgInstall(
}
if (!pathExists(indexPath)) {
throw new Error(`${indexPath} was not found. Copy a full NodeCG installation into lib/nodecg.`);
throw new Error(`${indexPath} was not found. Build the packaged NodeCG runtime before starting Electron.`);
}
if (!pathExists(nodecgBootstrapPath)) {
@@ -242,8 +242,8 @@ function validateNodecgInstall(
[
"NodeCG is present but internal dependencies are missing.",
`Not found: ${nodecgBootstrapPath}`,
"Solution: enter lib/nodecg and install dependencies:",
" npm install",
"Solution: rebuild the packaged runtime:",
" npm run prepare:runtime",
].join("\n"),
);
}
@@ -253,7 +253,7 @@ function validateNodecgInstall(
[
`Bundle '${bundleName}' was not found.`,
`Expected path: ${bundlePath}`,
"Copy/clone your bundle inside lib/nodecg/bundles before running Electron.",
"Build and package the Scoreko bundle before running Electron.",
].join("\n"),
);
}
+179
View File
@@ -0,0 +1,179 @@
import fs from "node:fs";
import path from "node:path";
type RuntimeProvisionerConfig = {
sourceRuntimePath: string;
userDataPath: string;
appVersion: string;
bundleName: string;
log: (...args: unknown[]) => void;
deps?: Partial<RuntimeProvisionerDeps>;
};
type RuntimeProvisionerDeps = {
existsSync: (candidatePath: string) => boolean;
mkdirSync: (candidatePath: string, options: { recursive: true }) => unknown;
rmSync: (candidatePath: string, options: { recursive: true; force: true }) => unknown;
cpSync: (
sourcePath: string,
targetPath: string,
options: {
recursive: true;
force: true;
dereference: true;
filter: (sourcePath: string) => boolean;
},
) => unknown;
readFileSync: (filePath: string) => string | Buffer;
writeFileSync: (filePath: string, content: string) => unknown;
};
type RuntimeManifest = {
appVersion?: unknown;
bundleName?: unknown;
sourceRuntime?: RuntimeManifest | null;
bundleVersion?: unknown;
nodecgVersion?: unknown;
};
const MANAGED_RUNTIME_MARKER = ".scoreko-installed-runtime.json";
const WRITABLE_NODECG_DIRS = ["cfg", "db", "logs"] as const;
const MANAGED_RUNTIME_ENTRIES = ["index.js", "package.json", "package-lock.json", "node_modules", "bundles"] as const;
export function prepareUserNodecgRuntime({
sourceRuntimePath,
userDataPath,
appVersion,
bundleName,
log,
deps,
}: RuntimeProvisionerConfig): string {
const resolvedDeps = resolveDeps(deps);
const targetRuntimePath = path.join(userDataPath, "nodecg");
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
if (shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps)) {
log(`Installing managed NodeCG runtime into ${targetRuntimePath}`);
installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
}
for (const writableDir of WRITABLE_NODECG_DIRS) {
resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true });
}
return targetRuntimePath;
}
function resolveDeps(deps?: Partial<RuntimeProvisionerDeps>): RuntimeProvisionerDeps {
return {
existsSync: deps?.existsSync ?? fs.existsSync,
mkdirSync: deps?.mkdirSync ?? fs.mkdirSync,
rmSync: deps?.rmSync ?? fs.rmSync,
cpSync: deps?.cpSync ?? fs.cpSync,
readFileSync: deps?.readFileSync ?? fs.readFileSync,
writeFileSync: deps?.writeFileSync ?? fs.writeFileSync,
};
}
function validateSourceRuntime(
sourceRuntimePath: string,
bundleName: string,
existsSync: RuntimeProvisionerDeps["existsSync"],
): void {
const requiredPaths = [
sourceRuntimePath,
path.join(sourceRuntimePath, "index.js"),
path.join(sourceRuntimePath, "package.json"),
path.join(sourceRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"),
path.join(sourceRuntimePath, "bundles", bundleName, "package.json"),
];
const missingPaths = requiredPaths.filter((candidatePath) => !existsSync(candidatePath));
if (missingPaths.length > 0) {
throw new Error(
[
"The packaged NodeCG runtime is incomplete.",
...missingPaths.map((missingPath) => `Missing: ${missingPath}`),
"Build the runtime with 'npm run prepare:runtime' before packaging or starting Electron.",
].join("\n"),
);
}
}
function shouldInstallRuntime(
sourceRuntimePath: string,
targetRuntimePath: string,
appVersion: string,
bundleName: string,
deps: RuntimeProvisionerDeps,
): boolean {
const targetBootstrap = path.join(targetRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const targetBundlePackage = path.join(targetRuntimePath, "bundles", bundleName, "package.json");
if (!deps.existsSync(targetBootstrap) || !deps.existsSync(targetBundlePackage)) {
return true;
}
const targetMarker = readJson(path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER), deps);
const sourceMarker = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps);
return (
targetMarker?.appVersion !== appVersion ||
targetMarker?.bundleName !== bundleName ||
targetMarker?.sourceRuntime?.bundleVersion !== sourceMarker?.bundleVersion ||
targetMarker?.sourceRuntime?.nodecgVersion !== sourceMarker?.nodecgVersion
);
}
function installManagedRuntime(
sourceRuntimePath: string,
targetRuntimePath: string,
appVersion: string,
bundleName: string,
deps: RuntimeProvisionerDeps,
): void {
for (const entry of MANAGED_RUNTIME_ENTRIES) {
deps.rmSync(path.join(targetRuntimePath, entry), { recursive: true, force: true });
}
deps.cpSync(sourceRuntimePath, targetRuntimePath, {
recursive: true,
force: true,
dereference: true,
filter: (sourcePath) => {
const relativePath = path.relative(sourceRuntimePath, sourcePath);
const firstSegment = relativePath.split(path.sep)[0];
return !WRITABLE_NODECG_DIRS.includes(firstSegment as (typeof WRITABLE_NODECG_DIRS)[number]);
},
});
const sourceRuntime = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps);
deps.writeFileSync(
path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER),
`${JSON.stringify(
{
appVersion,
bundleName,
sourceRuntime,
installedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
}
function readJson(filePath: string, deps: Pick<RuntimeProvisionerDeps, "existsSync" | "readFileSync">): RuntimeManifest | null {
if (!deps.existsSync(filePath)) {
return null;
}
try {
return JSON.parse(String(deps.readFileSync(filePath)));
} catch {
return null;
}
}