import { createReadStream, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createBalanceLabJobService } from "./balance-lab-jobs.mjs"; import { cloneDefaultCompanionCooldownPolicy } from "./companion-cooldown-policy.mjs"; import { cloneDefaultCompanionSpecReadinessPolicy, normalizeCompanionSpecReadinessPolicy, } from "./companion-spec-readiness.mjs"; import { GameDatabaseError } from "./database.mjs"; const API_ROOT = "/api/local/balance-lab"; function json(response, status, body) { const content = Buffer.from(JSON.stringify(body)); response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": content.length, "Cache-Control": "no-store", }); response.end(content); } async function readJson(request, limit) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > limit) throw new GameDatabaseError("Request body is too large.", 413, "body_too_large"); chunks.push(chunk); } if (size === 0) return {}; try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new GameDatabaseError("Request body must be valid JSON.", 400, "invalid_json"); } } function isLoopback(address) { return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; } export function createLocalBalanceLabApi(options = {}) { const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url))); const dataDir = path.resolve(options.dataDir ?? path.join(projectRoot, "artifacts", "balance-lab-gui-runtime")); const readinessPath = path.join(dataDir, "companion-spec-readiness.json"); mkdirSync(dataDir, { recursive: true }); let readiness = { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultCompanionSpecReadinessPolicy() }; try { const stored = JSON.parse(readFileSync(readinessPath, "utf8")); readiness = { revision: Number.isInteger(stored.revision) ? stored.revision : 0, updatedAt: Number.isFinite(stored.updatedAt) ? stored.updatedAt : null, updatedBy: typeof stored.updatedBy === "string" ? stored.updatedBy : null, config: normalizeCompanionSpecReadinessPolicy(stored.config), }; } catch { /* missing or corrupt local policy falls back to shipped defaults */ } const persistReadiness = () => { const temporaryPath = `${readinessPath}.tmp`; writeFileSync(temporaryPath, `${JSON.stringify(readiness, null, 2)}\n`, "utf8"); renameSync(temporaryPath, readinessPath); }; const service = createBalanceLabJobService({ dataDir, projectRoot, pythonExecutable: options.pythonExecutable, spawnProcess: options.spawnProcess, runtimeProbe: options.runtimeProbe, maxConcurrent: options.maxConcurrent, maxQueued: options.maxQueued, cooldownPolicyProvider: () => ({ config: cloneDefaultCompanionCooldownPolicy() }), readinessPolicyProvider: () => readiness, }); const account = Object.freeze({ username: "Local GUI" }); return { service, async handle(request, response) { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (url.pathname !== API_ROOT && !url.pathname.startsWith(`${API_ROOT}/`)) return false; if (!isLoopback(request.socket?.remoteAddress)) { json(response, 403, { error: "The local Balance Lab API accepts loopback requests only.", code: "loopback_required" }); return true; } try { if (url.pathname === `${API_ROOT}/companion-spec-readiness` && request.method === "GET") { json(response, 200, readiness); return true; } if (url.pathname === `${API_ROOT}/companion-spec-readiness` && request.method === "PUT") { const body = await readJson(request, 256 * 1024); if (!Number.isInteger(body.expectedRevision) || body.expectedRevision !== readiness.revision) { throw new GameDatabaseError("The companion readiness policy changed. Reload and try again.", 409, "revision_conflict"); } let config; try { config = normalizeCompanionSpecReadinessPolicy(body.config); } catch (error) { throw new GameDatabaseError( error instanceof Error ? error.message : "Invalid companion readiness policy.", 400, "invalid_companion_spec_readiness", ); } readiness = { revision: readiness.revision + 1, updatedAt: Date.now(), updatedBy: "Local GUI", config, }; persistReadiness(); json(response, 200, readiness); return true; } if (url.pathname === `${API_ROOT}/jobs` && request.method === "GET") { json(response, 200, { jobs: service.list() }); return true; } if (url.pathname === `${API_ROOT}/runtime` && request.method === "GET") { json(response, 200, { runtime: service.runtime() }); return true; } if (url.pathname === `${API_ROOT}/jobs/batch` && request.method === "POST") { json(response, 202, { jobs: service.startMany(await readJson(request, 4 * 1024 * 1024), account) }); return true; } if (url.pathname === `${API_ROOT}/jobs` && request.method === "POST") { json(response, 202, { job: service.start(await readJson(request, 32 * 1024), account) }); return true; } const artifactMatch = new RegExp(`^${API_ROOT}/jobs/([0-9a-f-]{36})/artifacts/([A-Za-z0-9._-]+)$`).exec(url.pathname); if (artifactMatch && request.method === "GET") { const artifact = service.artifact(artifactMatch[1], artifactMatch[2]); response.writeHead(200, { "Content-Type": artifact.contentType, "Content-Length": artifact.size, "Cache-Control": "no-store", "Content-Disposition": `inline; filename="${artifactMatch[2]}"`, "X-Content-Type-Options": "nosniff", }); createReadStream(artifact.filePath).pipe(response); return true; } const jobMatch = new RegExp(`^${API_ROOT}/jobs/([0-9a-f-]{36})$`).exec(url.pathname); if (jobMatch && request.method === "GET") { json(response, 200, { job: service.get(jobMatch[1]) }); return true; } json(response, 404, { error: "Local Balance Lab route not found.", code: "not_found" }); } catch (error) { const known = error instanceof GameDatabaseError; json(response, known ? error.status : 500, { error: known ? error.message : "The local Balance Lab request failed.", code: known ? error.code : "internal_error", }); } return true; }, close() { service.close(); }, }; }