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
+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;
}
}