// Copyright (c) 2026 Gordon Bolton. MIT License. const EventEmitter = require('events'); class LogEmitter extends EventEmitter {} const logEmitter = new LogEmitter(); const logBuffer = []; const clientLogBuffer = []; const MAX_BUFFER_SIZE = 1000; // ANSI escape code regular expression for stripping terminal colors const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; function stripAnsi(str) { return typeof str === 'string' ? str.replace(ansiRegex, '') : str; } // Keep track of original stdout/stderr write functions const originalStdoutWrite = process.stdout.write; const originalStderrWrite = process.stderr.write; // Buffer to accumulate partial lines from stdout and stderr let stdoutLineBuffer = ''; let stderrLineBuffer = ''; function processStreamData(data, encoding, callback, streamName, lineAccumulator) { let str = ''; if (Buffer.isBuffer(data)) { str = data.toString(encoding || 'utf8'); } else if (typeof data === 'string') { str = data; } // Delegate writing to the original stream first callback.call(this, data, encoding); // Append new data to the accumulator const accumulated = lineAccumulator.buffer + str; const lines = accumulated.split(/\r?\n/); // The last element is either empty (if str ended with \n) or a partial line lineAccumulator.buffer = lines.pop(); for (const line of lines) { const cleanLine = stripAnsi(line); if (!cleanLine) continue; // Prepend timestamp if not present (format: [ISO] Message) const timestampedLine = cleanLine.startsWith('[') ? cleanLine : `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${cleanLine}`; logBuffer.push(timestampedLine); if (logBuffer.length > MAX_BUFFER_SIZE) { logBuffer.shift(); } logEmitter.emit('server-log', timestampedLine); } } // Accumulator objects to allow updating string buffers by reference const stdoutAccumulator = { buffer: '' }; const stderrAccumulator = { buffer: '' }; let isHooked = false; function init() { if (isHooked) return; // Intercept stdout process.stdout.write = function(data, encoding, callback) { processStreamData.call( process.stdout, data, encoding, originalStdoutWrite, 'stdout', stdoutAccumulator ); if (typeof callback === 'function') callback(); return true; }; // Intercept stderr process.stderr.write = function(data, encoding, callback) { processStreamData.call( process.stderr, data, encoding, originalStderrWrite, 'stderr', stderrAccumulator ); if (typeof callback === 'function') callback(); return true; }; isHooked = true; } /** * Ingests a list of client-side logs into the rolling clientLogBuffer. * Each client log is expected to have structure: { timestamp, level, message } */ function ingestClientLogs(logs) { if (!Array.isArray(logs)) return; for (const log of logs) { const timestamp = log.timestamp || new Date().toISOString(); const level = (log.level || 'info').toUpperCase(); const msg = typeof log.message === 'string' ? log.message : JSON.stringify(log.message); const formattedLog = `[${timestamp}] [CLIENT] [${level}] ${stripAnsi(msg)}`; clientLogBuffer.push(formattedLog); if (clientLogBuffer.length > MAX_BUFFER_SIZE) { clientLogBuffer.shift(); } logEmitter.emit('client-log', formattedLog); } } module.exports = { init, logEmitter, logBuffer, clientLogBuffer, ingestClientLogs };