full sync
This commit is contained in:
796
server.js
Normal file
796
server.js
Normal file
@@ -0,0 +1,796 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { spawn } = require("child_process");
|
||||
const express = require("express");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const PORT = Number(process.env.PORT || 8081);
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const DB_DIR = path.join(__dirname, "data");
|
||||
const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
|
||||
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
|
||||
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
|
||||
const WATCHED_FILES = [
|
||||
path.join(__dirname, "index.html"),
|
||||
path.join(__dirname, "src", "app.js"),
|
||||
path.join(__dirname, "src", "styles.css"),
|
||||
];
|
||||
|
||||
fs.mkdirSync(DB_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
initSchema();
|
||||
const appVersion = {
|
||||
revision: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
watchAppFiles();
|
||||
const ammcState = {
|
||||
process: null,
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
stoppedAt: null,
|
||||
lastExitCode: null,
|
||||
lastExitSignal: null,
|
||||
lastError: "",
|
||||
lastOutput: [],
|
||||
config: loadAmmcConfig(),
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "20mb" }));
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true, dbPath: DB_PATH });
|
||||
});
|
||||
|
||||
app.get("/api/app-version", (_req, res) => {
|
||||
res.json(appVersion);
|
||||
});
|
||||
|
||||
app.get("/api/ammc/config", (_req, res) => {
|
||||
res.json({
|
||||
config: ammcState.config,
|
||||
status: buildAmmcStatus(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/ammc/config", (req, res) => {
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
res.status(400).json({ error: "Expected JSON body" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ammcState.config = normalizeAmmcConfig(req.body, ammcState.config);
|
||||
saveAmmcConfig(ammcState.config);
|
||||
res.json({
|
||||
ok: true,
|
||||
config: ammcState.config,
|
||||
status: buildAmmcStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
status: buildAmmcStatus(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/ammc/status", (_req, res) => {
|
||||
res.json(buildAmmcStatus());
|
||||
});
|
||||
|
||||
app.post("/api/ammc/start", (_req, res) => {
|
||||
try {
|
||||
startAmmcProcess();
|
||||
res.json({ ok: true, status: buildAmmcStatus() });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
status: buildAmmcStatus(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/ammc/stop", async (_req, res) => {
|
||||
try {
|
||||
await stopAmmcProcess();
|
||||
res.json({ ok: true, status: buildAmmcStatus() });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
status: buildAmmcStatus(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/state", (_req, res) => {
|
||||
const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get();
|
||||
if (!row) {
|
||||
res.json({ state: null, updatedAt: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.json({ state: JSON.parse(row.state_json), updatedAt: row.updated_at });
|
||||
} catch {
|
||||
res.status(500).json({ error: "Stored app state is invalid JSON" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/state", (req, res) => {
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
res.status(400).json({ error: "Expected JSON body" });
|
||||
return;
|
||||
}
|
||||
|
||||
const stateJson = JSON.stringify(req.body);
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO app_state (id, state_json, updated_at)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
state_json = excluded.state_json,
|
||||
updated_at = excluded.updated_at
|
||||
`
|
||||
).run(stateJson, nowIso);
|
||||
|
||||
res.json({ ok: true, updatedAt: nowIso });
|
||||
});
|
||||
|
||||
app.post("/api/passings", (req, res) => {
|
||||
const { sessionId, passing } = req.body || {};
|
||||
if (!sessionId || !passing || typeof passing !== "object") {
|
||||
res.status(400).json({ error: "Expected { sessionId, passing }" });
|
||||
return;
|
||||
}
|
||||
|
||||
const stmt = db.prepare(
|
||||
`
|
||||
INSERT INTO passings (
|
||||
session_id,
|
||||
timestamp_ms,
|
||||
transponder,
|
||||
driver_id,
|
||||
driver_name,
|
||||
car_id,
|
||||
car_name,
|
||||
strength,
|
||||
loop_id,
|
||||
resend,
|
||||
raw_json,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
);
|
||||
|
||||
stmt.run(
|
||||
String(sessionId),
|
||||
Number(passing.timestamp || Date.now()),
|
||||
String(passing.transponder || ""),
|
||||
passing.driverId ? String(passing.driverId) : null,
|
||||
passing.driverName ? String(passing.driverName) : null,
|
||||
passing.carId ? String(passing.carId) : null,
|
||||
passing.carName ? String(passing.carName) : null,
|
||||
typeof passing.strength === "number" ? passing.strength : null,
|
||||
passing.loopId ? String(passing.loopId) : null,
|
||||
passing.resend ? 1 : 0,
|
||||
JSON.stringify(passing),
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/passings", (req, res) => {
|
||||
const sessionId = req.query.sessionId ? String(req.query.sessionId) : null;
|
||||
const limit = Math.min(1000, Math.max(1, Number(req.query.limit || 200)));
|
||||
|
||||
const rows = sessionId
|
||||
? db
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM passings
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp_ms DESC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(sessionId, limit)
|
||||
: db
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM passings
|
||||
ORDER BY timestamp_ms DESC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(limit);
|
||||
|
||||
res.json({ rows });
|
||||
});
|
||||
|
||||
app.post("/api/export/pdf", (req, res) => {
|
||||
try {
|
||||
const payload = normalizePdfExportPayload(req.body || {});
|
||||
const pdf = buildSimplePdf(payload);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${payload.filename}"`);
|
||||
res.send(pdf);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.use(
|
||||
express.static(__dirname, {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
writePidFile();
|
||||
console.log(`RC Timing server listening on http://${HOST}:${PORT}`);
|
||||
const lanUrls = getLanUrls(PORT);
|
||||
if (lanUrls.length) {
|
||||
console.log(`LAN access: ${lanUrls.join(" , ")}`);
|
||||
}
|
||||
console.log(`SQLite database: ${DB_PATH}`);
|
||||
console.log(`AMMC executable: ${buildAmmcStatus().resolvedExecutablePath}`);
|
||||
maybeStartManagedAmmc();
|
||||
});
|
||||
|
||||
function initSchema() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS passings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
transponder TEXT NOT NULL,
|
||||
driver_id TEXT,
|
||||
driver_name TEXT,
|
||||
car_id TEXT,
|
||||
car_name TEXT,
|
||||
strength REAL,
|
||||
loop_id TEXT,
|
||||
resend INTEGER NOT NULL DEFAULT 0,
|
||||
raw_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_passings_session ON passings(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_passings_timestamp ON passings(timestamp_ms);
|
||||
`);
|
||||
}
|
||||
|
||||
function normalizePdfExportPayload(input = {}) {
|
||||
const title = String(input.title || "JMK RB Live Event").trim();
|
||||
const subtitle = String(input.subtitle || "").trim();
|
||||
const brandName = String(input.brandName || "JMK RB").trim();
|
||||
const brandTagline = String(input.brandTagline || "Live Event").trim();
|
||||
const footer = String(input.footer || "").trim();
|
||||
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
|
||||
? String(input.theme).toLowerCase()
|
||||
: "classic";
|
||||
const filenameBase = String(input.filename || "export.pdf").trim() || "export.pdf";
|
||||
const filename = filenameBase.toLowerCase().endsWith(".pdf") ? filenameBase : `${filenameBase}.pdf`;
|
||||
const sections = Array.isArray(input.sections)
|
||||
? input.sections.map((section) => ({
|
||||
title: String(section?.title || "").trim(),
|
||||
headers: Array.isArray(section?.headers) ? section.headers.map((value) => String(value || "").trim()) : [],
|
||||
rows: Array.isArray(section?.rows)
|
||||
? section.rows.map((row) => (Array.isArray(row) ? row.map((value) => String(value ?? "").trim()) : [String(row ?? "").trim()]))
|
||||
: [],
|
||||
}))
|
||||
: [];
|
||||
|
||||
return { title, subtitle, brandName, brandTagline, footer, theme, filename, sections };
|
||||
}
|
||||
|
||||
function buildSimplePdf(payload) {
|
||||
const pageWidth = 595;
|
||||
const pageHeight = 842;
|
||||
const marginLeft = 42;
|
||||
const marginTop = 56;
|
||||
const lineHeight = 15;
|
||||
const maxChars = 86;
|
||||
const lines = buildPdfLines(payload, maxChars);
|
||||
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop * 2) / lineHeight));
|
||||
const pages = [];
|
||||
for (let index = 0; index < lines.length; index += linesPerPage) {
|
||||
pages.push(lines.slice(index, index + linesPerPage));
|
||||
}
|
||||
if (!pages.length) {
|
||||
pages.push(["No content"]);
|
||||
}
|
||||
|
||||
const objects = [];
|
||||
objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
||||
|
||||
const kids = [];
|
||||
const fontObjectId = 3 + pages.length * 2;
|
||||
for (let index = 0; index < pages.length; index += 1) {
|
||||
const pageObjectId = 3 + index * 2;
|
||||
const contentObjectId = 4 + index * 2;
|
||||
kids.push(`${pageObjectId} 0 R`);
|
||||
const contentStream = buildPdfContentStream(pages[index], marginLeft, pageHeight - marginTop, lineHeight);
|
||||
objects.push(
|
||||
`${pageObjectId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 ${fontObjectId} 0 R >> >> /Contents ${contentObjectId} 0 R >>\nendobj\n`
|
||||
);
|
||||
objects.push(`${contentObjectId} 0 obj\n<< /Length ${Buffer.byteLength(contentStream, "utf8")} >>\nstream\n${contentStream}\nendstream\nendobj\n`);
|
||||
}
|
||||
objects.splice(1, 0, `2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids.join(" ")}] >>\nendobj\n`);
|
||||
objects.push(`${fontObjectId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`);
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets = [0];
|
||||
objects.forEach((object) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "utf8"));
|
||||
pdf += object;
|
||||
});
|
||||
const xrefOffset = Buffer.byteLength(pdf, "utf8");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += "0000000000 65535 f \n";
|
||||
offsets.slice(1).forEach((offset) => {
|
||||
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
});
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||
return Buffer.from(pdf, "utf8");
|
||||
}
|
||||
|
||||
function buildPdfLines(payload, maxChars) {
|
||||
const lines = [];
|
||||
const separator =
|
||||
payload.theme === "minimal"
|
||||
? ""
|
||||
: payload.theme === "motorsport"
|
||||
? "=".repeat(Math.min(maxChars, 70))
|
||||
: "-".repeat(Math.min(maxChars, 70));
|
||||
if (payload.brandName) {
|
||||
lines.push(payload.brandName);
|
||||
}
|
||||
if (payload.brandTagline) {
|
||||
lines.push(payload.brandTagline);
|
||||
}
|
||||
if (separator) {
|
||||
lines.push(separator);
|
||||
}
|
||||
if (payload.brandName || payload.brandTagline) {
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(payload.title);
|
||||
if (payload.subtitle) {
|
||||
lines.push(payload.subtitle);
|
||||
}
|
||||
payload.sections.forEach((section) => {
|
||||
lines.push("");
|
||||
if (section.title) {
|
||||
lines.push(payload.theme === "motorsport" ? `> ${section.title}` : section.title);
|
||||
}
|
||||
if (section.headers.length) {
|
||||
const widths = getColumnWidths(section.headers, section.rows, maxChars);
|
||||
lines.push(formatPdfRow(section.headers, widths));
|
||||
lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths));
|
||||
section.rows.forEach((row) => {
|
||||
lines.push(formatPdfRow(row, widths));
|
||||
});
|
||||
} else {
|
||||
section.rows.forEach((row) => {
|
||||
row.forEach((value) => {
|
||||
wrapPdfText(value, maxChars).forEach((line) => lines.push(line));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
if (payload.footer) {
|
||||
lines.push("");
|
||||
lines.push(payload.footer);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function getColumnWidths(headers, rows, maxChars) {
|
||||
const widths = headers.map((header, index) => {
|
||||
const columnValues = rows.map((row) => String(row[index] || ""));
|
||||
const longest = Math.max(header.length, ...columnValues.map((value) => value.length), 4);
|
||||
return Math.min(26, Math.max(4, longest));
|
||||
});
|
||||
let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, (widths.length - 1) * 3);
|
||||
while (total > maxChars) {
|
||||
const widestIndex = widths.findIndex((width) => width === Math.max(...widths));
|
||||
if (widths[widestIndex] <= 4) {
|
||||
break;
|
||||
}
|
||||
widths[widestIndex] -= 1;
|
||||
total -= 1;
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
function formatPdfRow(values, widths) {
|
||||
return values
|
||||
.map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " "))
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
function wrapPdfText(text, maxChars) {
|
||||
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||
if (!words.length) {
|
||||
return [""];
|
||||
}
|
||||
const lines = [];
|
||||
let current = "";
|
||||
words.forEach((word) => {
|
||||
const next = current ? `${current} ${word}` : word;
|
||||
if (next.length > maxChars) {
|
||||
if (current) {
|
||||
lines.push(current);
|
||||
current = word;
|
||||
} else {
|
||||
lines.push(word.slice(0, maxChars));
|
||||
current = word.slice(maxChars);
|
||||
}
|
||||
} else {
|
||||
current = next;
|
||||
}
|
||||
});
|
||||
if (current) {
|
||||
lines.push(current);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildPdfContentStream(lines, x, y, lineHeight) {
|
||||
const escaped = lines.map((line) => escapePdfText(line));
|
||||
return [
|
||||
"BT",
|
||||
"/F1 11 Tf",
|
||||
`${lineHeight} TL`,
|
||||
`${x} ${y} Td`,
|
||||
...escaped.map((line) => `(${line}) Tj\nT*`),
|
||||
"ET",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function escapePdfText(text) {
|
||||
return String(text || "")
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll("(", "\\(")
|
||||
.replaceAll(")", "\\)");
|
||||
}
|
||||
|
||||
function getLanUrls(port) {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const urls = [];
|
||||
Object.values(interfaces).forEach((records) => {
|
||||
(records || []).forEach((rec) => {
|
||||
if (rec && rec.family === "IPv4" && !rec.internal) {
|
||||
urls.push(`http://${rec.address}:${port}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
function watchAppFiles() {
|
||||
WATCHED_FILES.forEach((filePath) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.watchFile(filePath, { interval: 1000 }, (curr, prev) => {
|
||||
if (curr.mtimeMs !== prev.mtimeMs) {
|
||||
bumpAppVersion();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bumpAppVersion() {
|
||||
appVersion.revision += 1;
|
||||
appVersion.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
function getDefaultAmmcExecutablePath(platform = process.platform) {
|
||||
if (platform === "win32") {
|
||||
return path.join(__dirname, "AMMC", "windows64", "ammc-amb.exe");
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
return path.join(__dirname, "AMMC", "apple_m", "ammc-amb");
|
||||
}
|
||||
return path.join(__dirname, "AMMC", "linux_x86-64", "ammc-amb");
|
||||
}
|
||||
|
||||
function normalizeAmmcConfig(input = {}, previous = {}) {
|
||||
const decoderHost = String(input.decoderHost ?? previous.decoderHost ?? "").trim();
|
||||
const executablePath = String(input.executablePath ?? previous.executablePath ?? getDefaultAmmcExecutablePath()).trim();
|
||||
const workingDirectory = String(input.workingDirectory ?? previous.workingDirectory ?? "").trim();
|
||||
const extraArgs = String(input.extraArgs ?? previous.extraArgs ?? "").trim();
|
||||
const wsPortValue = Number(input.wsPort ?? previous.wsPort ?? 9000);
|
||||
if (!Number.isFinite(wsPortValue) || wsPortValue < 1 || wsPortValue > 65535) {
|
||||
throw new Error("AMMC WebSocket port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
return {
|
||||
managedEnabled: Boolean(input.managedEnabled ?? previous.managedEnabled ?? false),
|
||||
autoStart: Boolean(input.autoStart ?? previous.autoStart ?? false),
|
||||
decoderHost,
|
||||
wsPort: Math.round(wsPortValue),
|
||||
executablePath,
|
||||
workingDirectory,
|
||||
extraArgs,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function loadAmmcConfig() {
|
||||
if (!fs.existsSync(AMMC_CONFIG_PATH)) {
|
||||
return normalizeAmmcConfig({
|
||||
managedEnabled: false,
|
||||
autoStart: false,
|
||||
decoderHost: "",
|
||||
wsPort: 9000,
|
||||
executablePath: getDefaultAmmcExecutablePath(),
|
||||
workingDirectory: "",
|
||||
extraArgs: "",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(AMMC_CONFIG_PATH, "utf8");
|
||||
return normalizeAmmcConfig(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
console.error(`Failed to load AMMC config: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return normalizeAmmcConfig({
|
||||
managedEnabled: false,
|
||||
autoStart: false,
|
||||
decoderHost: "",
|
||||
wsPort: 9000,
|
||||
executablePath: getDefaultAmmcExecutablePath(),
|
||||
workingDirectory: "",
|
||||
extraArgs: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function saveAmmcConfig(config) {
|
||||
fs.writeFileSync(AMMC_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function resolveAmmcExecutable(executablePath) {
|
||||
const value = String(executablePath || "").trim();
|
||||
if (!value) {
|
||||
return getDefaultAmmcExecutablePath();
|
||||
}
|
||||
if (path.isAbsolute(value)) {
|
||||
return value;
|
||||
}
|
||||
return path.join(__dirname, value);
|
||||
}
|
||||
|
||||
function resolveAmmcWorkingDirectory(config, executablePath) {
|
||||
const explicitDir = String(config.workingDirectory || "").trim();
|
||||
if (!explicitDir) {
|
||||
return path.dirname(executablePath);
|
||||
}
|
||||
return path.isAbsolute(explicitDir) ? explicitDir : path.join(__dirname, explicitDir);
|
||||
}
|
||||
|
||||
function parseExtraArgs(extraArgs) {
|
||||
return String(extraArgs || "")
|
||||
.split(/\s+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isAmmcRunning() {
|
||||
return Boolean(ammcState.process && ammcState.process.exitCode == null && !ammcState.process.killed);
|
||||
}
|
||||
|
||||
function buildAmmcStatus() {
|
||||
const resolvedExecutablePath = resolveAmmcExecutable(ammcState.config.executablePath);
|
||||
const workingDirectory = resolveAmmcWorkingDirectory(ammcState.config, resolvedExecutablePath);
|
||||
return {
|
||||
running: isAmmcRunning(),
|
||||
pid: ammcState.pid,
|
||||
startedAt: ammcState.startedAt,
|
||||
stoppedAt: ammcState.stoppedAt,
|
||||
lastExitCode: ammcState.lastExitCode,
|
||||
lastExitSignal: ammcState.lastExitSignal,
|
||||
lastError: ammcState.lastError,
|
||||
lastOutput: ammcState.lastOutput,
|
||||
serverPlatform: process.platform,
|
||||
executableExists: fs.existsSync(resolvedExecutablePath),
|
||||
resolvedExecutablePath,
|
||||
workingDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
function appendAmmcOutput(stream, chunk) {
|
||||
const lines = String(chunk || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
lines.forEach((line) => {
|
||||
ammcState.lastOutput.push({
|
||||
ts: new Date().toISOString(),
|
||||
stream,
|
||||
line,
|
||||
});
|
||||
});
|
||||
|
||||
if (ammcState.lastOutput.length > 30) {
|
||||
ammcState.lastOutput.splice(0, ammcState.lastOutput.length - 30);
|
||||
}
|
||||
}
|
||||
|
||||
function startAmmcProcess() {
|
||||
if (isAmmcRunning()) {
|
||||
return buildAmmcStatus();
|
||||
}
|
||||
|
||||
const config = normalizeAmmcConfig(ammcState.config, ammcState.config);
|
||||
ammcState.config = config;
|
||||
saveAmmcConfig(config);
|
||||
|
||||
if (!config.managedEnabled) {
|
||||
throw new Error("Enable managed AMMC first");
|
||||
}
|
||||
if (!config.decoderHost) {
|
||||
throw new Error("Set decoder IP/host before starting AMMC");
|
||||
}
|
||||
|
||||
const executablePath = resolveAmmcExecutable(config.executablePath);
|
||||
if (!fs.existsSync(executablePath)) {
|
||||
throw new Error(`AMMC executable not found: ${executablePath}`);
|
||||
}
|
||||
|
||||
const args = ["-w", String(config.wsPort), config.decoderHost, ...parseExtraArgs(config.extraArgs)];
|
||||
const workingDirectory = resolveAmmcWorkingDirectory(config, executablePath);
|
||||
|
||||
ammcState.lastError = "";
|
||||
appendAmmcOutput("system", `Starting ${path.basename(executablePath)} ${args.join(" ")}`);
|
||||
|
||||
const child = spawn(executablePath, args, {
|
||||
cwd: workingDirectory,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
ammcState.process = child;
|
||||
ammcState.pid = child.pid || null;
|
||||
ammcState.startedAt = new Date().toISOString();
|
||||
ammcState.stoppedAt = null;
|
||||
ammcState.lastExitCode = null;
|
||||
ammcState.lastExitSignal = null;
|
||||
|
||||
child.stdout.on("data", (chunk) => appendAmmcOutput("stdout", chunk));
|
||||
child.stderr.on("data", (chunk) => appendAmmcOutput("stderr", chunk));
|
||||
child.on("error", (error) => {
|
||||
ammcState.lastError = error instanceof Error ? error.message : String(error);
|
||||
appendAmmcOutput("error", ammcState.lastError);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
ammcState.lastExitCode = code;
|
||||
ammcState.lastExitSignal = signal;
|
||||
ammcState.stoppedAt = new Date().toISOString();
|
||||
appendAmmcOutput("system", `AMMC stopped (code=${code}, signal=${signal || "none"})`);
|
||||
ammcState.process = null;
|
||||
ammcState.pid = null;
|
||||
});
|
||||
|
||||
return buildAmmcStatus();
|
||||
}
|
||||
|
||||
function stopAmmcProcess() {
|
||||
if (!isAmmcRunning() || !ammcState.process) {
|
||||
return Promise.resolve(buildAmmcStatus());
|
||||
}
|
||||
|
||||
const child = ammcState.process;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(buildAmmcStatus());
|
||||
}
|
||||
};
|
||||
|
||||
child.once("exit", finish);
|
||||
child.kill();
|
||||
|
||||
setTimeout(() => {
|
||||
if (!settled && isAmmcRunning()) {
|
||||
if (process.platform !== "win32") {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
finish();
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function maybeStartManagedAmmc() {
|
||||
if (!ammcState.config.managedEnabled || !ammcState.config.autoStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startAmmcProcess();
|
||||
console.log("Managed AMMC started");
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ammcState.lastError = msg;
|
||||
console.error(`Managed AMMC failed to start: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
await stopAmmcProcess();
|
||||
removePidFile();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("exit", removePidFile);
|
||||
|
||||
function writePidFile() {
|
||||
if (!PID_FILE) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(PID_FILE, `${process.pid}\n`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to write pid file: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function removePidFile() {
|
||||
if (!PID_FILE || !fs.existsSync(PID_FILE)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
|
||||
if (String(process.pid) === raw) {
|
||||
fs.rmSync(PID_FILE, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore pid cleanup errors on shutdown
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user