Update Healer Man server
This commit is contained in:
1 parent
f8314e7de1
commit
1fedb9368d
76 files changed
+20556
-2523
No files matched your search
@@ -0,0 +1,164 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user