Files
1960-utils/ReforgerOutputConsole/server.js
T
2026-09-26 18:44:15 +02:00

235 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Reforger Output Console
// Watches several log folders, picks the newest subfolder of each and
// streams the log file inside it live to a browser UI.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const { StringDecoder } = require('string_decoder');
const CONFIG_PATH = path.join(__dirname, 'config.json');
const INDEX_PATH = path.join(__dirname, 'index.html');
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const PORT = config.port || 8420;
const POLL_MS = config.pollIntervalMs || 500;
const MAX_LINES = config.maxLinesPerPane || 20000;
// Server-side buffer so newly connected browsers receive the existing content.
const MAX_CHARS = MAX_LINES * 300;
const CRASH_FILE = config.crashFile || 'crash.log';
const panes = config.panes.map((p, i) => ({
id: i,
name: p.name || path.basename(path.dirname(p.logsDir)),
logsDir: path.resolve(__dirname, p.logsDir),
file: p.file || 'console.log',
folder: undefined,
offset: 0,
decoder: new StringDecoder('utf8'),
text: '',
dirMissing: false,
crash: null,
crashStamp: '',
}));
const clients = new Set();
function broadcast(event, data) {
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of clients) res.write(msg);
}
function snapshot(p) {
return {
id: p.id,
name: p.name,
file: p.file,
folder: p.folder || null,
logsDir: p.logsDir,
dirMissing: p.dirMissing,
text: p.text,
crash: p.crash,
};
}
// Newest subfolder by creation time (ties broken by name).
function newestFolder(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return undefined;
}
let best = null;
let bestTime = -Infinity;
for (const e of entries) {
if (!e.isDirectory()) continue;
let t;
try {
t = fs.statSync(path.join(dir, e.name)).birthtimeMs;
} catch {
continue;
}
if (t > bestTime || (t === bestTime && e.name > best)) {
best = e.name;
bestTime = t;
}
}
return best;
}
function resetPane(p) {
p.offset = 0;
p.decoder = new StringDecoder('utf8');
p.text = '';
p.crash = null;
p.crashStamp = '';
broadcast('reset', snapshot(p));
}
// The crash report is small and is resent in full on every change.
function pollCrash(p) {
const file = path.join(p.logsDir, p.folder, CRASH_FILE);
let st;
try {
st = fs.statSync(file);
} catch {
return;
}
const stamp = `${st.size}:${st.mtimeMs}`;
if (stamp === p.crashStamp) return;
let text;
try {
text = fs.readFileSync(file, 'utf8');
} catch {
return;
}
if (p.crash === null) console.log(`[${p.name}] Crash report found: ${p.folder}\\${CRASH_FILE}`);
p.crashStamp = stamp;
p.crash = text;
broadcast('crash', { id: p.id, text });
}
function pollPane(p) {
const folder = newestFolder(p.logsDir);
const dirMissing = folder === undefined;
if (folder !== p.folder || dirMissing !== p.dirMissing) {
if (p.folder !== undefined && folder) {
console.log(`[${p.name}] New log folder: ${folder}`);
}
p.folder = folder;
p.dirMissing = dirMissing;
resetPane(p);
}
if (!folder) return;
pollCrash(p);
const file = path.join(p.logsDir, folder, p.file);
let size;
try {
size = fs.statSync(file).size;
} catch {
return; // file doesn't exist (yet)
}
if (size < p.offset) resetPane(p); // file was truncated/rewritten
if (size === p.offset) return;
let start = p.offset;
if (size - start > MAX_CHARS) start = size - MAX_CHARS;
const len = size - start;
const buf = Buffer.alloc(len);
let fd;
try {
fd = fs.openSync(file, 'r');
fs.readSync(fd, buf, 0, len, start);
} catch {
return;
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
p.offset = size;
const chunk = p.decoder.write(buf);
if (!chunk) return;
p.text += chunk;
if (p.text.length > MAX_CHARS) {
const cut = p.text.indexOf('\n', p.text.length - MAX_CHARS);
p.text = p.text.slice(cut + 1);
}
broadcast('append', { id: p.id, chunk });
}
function pollAll() {
for (const p of panes) {
try {
pollPane(p);
} catch (err) {
console.error(`[${p.name}]`, err.message);
}
}
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
fs.createReadStream(INDEX_PATH).pipe(res);
return;
}
if (url.pathname === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(`event: init\ndata: ${JSON.stringify({ maxLines: MAX_LINES, panes: panes.map(snapshot) })}\n\n`);
clients.add(res);
req.on('close', () => clients.delete(res));
return;
}
// Opens a pane's current log folder in Explorer.
if (url.pathname === '/open' && req.method === 'POST') {
const p = panes[Number(url.searchParams.get('id'))];
if (p) {
const dir = p.folder ? path.join(p.logsDir, p.folder) : p.logsDir;
spawn('explorer', [dir], { detached: true, stdio: 'ignore' }).unref();
}
res.writeHead(204).end();
return;
}
res.writeHead(404).end();
});
// Keep-alive so connections aren't closed due to inactivity.
setInterval(() => {
for (const res of clients) res.write(': ping\n\n');
}, 15000);
server.listen(PORT, '127.0.0.1', () => {
const url = `http://localhost:${PORT}`;
console.log(`Reforger Output Console running at ${url}`);
for (const p of panes) console.log(` - ${p.name}: ${p.logsDir}\\<newest folder>\\${p.file}`);
console.log('Press Ctrl+C to stop.');
pollAll();
setInterval(pollAll, POLL_MS);
if (config.openBrowser !== false) {
spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
}
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is already in use – is the console already running? Otherwise change "port" in config.json.`);
} else {
console.error(err);
}
process.exit(1);
});