'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 this.file = null this.bytesWritten = 0 this.rotating = false this.closed = false if (file) this.openFile(file) } openFile(file) { this.file = file try { fs.mkdirSync(path.dirname(file), { recursive: true }) if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) this.rotateFile() this.stream = fs.createWriteStream(file, { flags: 'a' }) this.bytesWritten = fs.existsSync(file) ? fs.statSync(file).size : 0 } catch (err) { this.stream = null this.push('console', `日志文件不可写,仅保留内存日志:${err.message}`) } } // 把当前日志改名成 .1:Windows 上 rename 到已存在的目标会失败,先删掉旧 .1 rotateFile() { const rotated = `${this.file}.1` try { fs.rmSync(rotated, { force: true }) } catch { // 删不掉就让 rename 失败,由调用方落到「仅内存日志」兜底 } if (fs.existsSync(this.file)) fs.renameSync(this.file, rotated) } // 运行中轮转:push 时按累计字节数触发,否则长赛事跑满一天文件会无限增长。 // 必须先关流把缓冲刷盘再改名(Windows 下打开中的文件不能改名), // 轮转窗口内新日志只进内存缓冲,窗口很短。 rotate() { if (!this.file || this.rotating) return this.rotating = true const stream = this.stream this.stream = null this.bytesWritten = 0 const reopen = () => { this.rotating = false if (this.closed) return try { this.rotateFile() } catch (err) { this.push('console', `日志文件轮转失败,仅保留内存日志:${err.message}`) return } this.stream = fs.createWriteStream(this.file, { flags: 'a' }) } if (stream) stream.end(reopen) else reopen() } 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) { const text = `${line}\n` const bytes = Buffer.byteLength(text) if (this.maxFileBytes > 0 && this.bytesWritten + bytes > this.maxFileBytes) this.rotate() if (this.stream) { this.stream.write(text) this.bytesWritten += bytes } } 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() { this.closed = true if (this.stream) { this.stream.end() this.stream = null } } } module.exports = { LogBus }