'use strict' const fs = require('fs') const path = require('path') // 日志总线:内存环形缓冲供管理窗口实时查看,同时落一份文件供现场排障。 // 现场没有外网也没有终端,文件日志是事后定位问题的唯一线索。 class LogBus { constructor(options = {}) { const { maxLines = 2000, file = null, maxFileBytes = 4 * 1024 * 1024 } = options this.maxLines = maxLines this.maxFileBytes = maxFileBytes this.lines = [] this.listeners = new Set() // 子进程的输出是流式分片的,可能把一行拆到两次 data 事件里 this.pending = new Map() this.stream = null if (file) this.openFile(file) } openFile(file) { try { fs.mkdirSync(path.dirname(file), { recursive: true }) if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) { fs.renameSync(file, `${file}.1`) } this.stream = fs.createWriteStream(file, { flags: 'a' }) } catch (err) { this.stream = null this.push('console', `日志文件不可写,仅保留内存日志:${err.message}`) } } append(source, chunk) { const text = `${this.pending.get(source) || ''}${String(chunk)}` const parts = text.split(/\r?\n/) // 最后一段是没有换行的残行,留到下一次拼接 this.pending.set(source, parts.pop() || '') for (const part of parts) this.push(source, part) } push(source, message) { const line = `[${new Date().toISOString()}] [${source}] ${message}` this.lines.push(line) if (this.lines.length > this.maxLines) { this.lines.splice(0, this.lines.length - this.maxLines) } if (this.stream) this.stream.write(`${line}\n`) for (const listener of this.listeners) { try { listener(line) } catch { // 单个订阅者出错不能拖垮日志链路 } } } tail() { return this.lines.slice() } clear() { this.lines = [] } onLine(listener) { this.listeners.add(listener) return () => this.listeners.delete(listener) } close() { if (this.stream) { this.stream.end() this.stream = null } } } module.exports = { LogBus }