165 lines
6.8 KiB
JavaScript
165 lines
6.8 KiB
JavaScript
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();
|
|
},
|
|
};
|
|
}
|