chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
import { WriteStream } from "fs";
import * as fs from "node:fs";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import {log, font_style} from "../types/utils/logger";
export interface LogHistoryRecord {
id: string;
timestamp: number;
log: log[];
}
export interface _Logger {
info(msg: string): void;
success(msg: string): void;
error(msg: string): void;
warning(msg: string): void;
tip(msg: string): void;
custom(msg: string, font_color: string, font_style: font_style[]): void;
log(log: log[]): void;
get_recent_log(count: number): log[][];
read_recent_log(count: number): Promise<LogHistoryRecord[]>;
close(): Promise<void>;
}
export class Logger implements _Logger {
// 单个日志文件的最大字节数(50M)
private static readonly MAX_FILE_SIZE = 50 * 1024 * 1024;
// 同一目录下保留的最大日志文件数
private static readonly MAX_FILES = 10;
// 日志文件名格式:year-month-day_log.log / year-month-day_log_{index}.log
private static readonly LOG_FILE_REGEX = /^\d{4}-\d{2}-\d{2}_log(_\d+)?\.log$/;
// 重建写入流的最大失败次数与重试延时
private static readonly MAX_RECREATE_FAILURES = 10;
private static readonly RECREATE_DELAY = 1000;
private readonly log_dir: string;
private writer!: WriteStream;
// 当前日志所属日期(year-month-day)
private day = '';
// 当天的文件序号,0 为当天首个文件
private index = 0;
private size = 0;
private write_queue: string[] = [];
private in_flight_chunk: string | undefined;
private writing = false;
private recreate_failures = 0;
private recreate_timer: NodeJS.Timeout | null = null;
private stopped = false;
private close_task: Promise<void> | undefined;
constructor(log_dir: string) {
this.log_dir = log_dir;
this.day = this.format_day(new Date());
this.open_writer();
}
private bind_writer_error(): void {
this.writer.on('error', (err: NodeJS.ErrnoException) => {
this.writing = false;
// 仅处理文件不存在,重建写入流;其他错误交由后续写入重试
if (err.code === 'ENOENT') {
this.schedule_recreate();
}
});
}
// 合并并发错误,避免同一时刻重复创建
private schedule_recreate(): void {
if (this.stopped || this.recreate_timer !== null) {
return;
}
this.recreate_timer = setTimeout(() => {
this.recreate_timer = null;
this.recreate_writer();
}, Logger.RECREATE_DELAY);
this.recreate_timer.unref();
}
// 重新创建写入流;累计失败达到上限后停止创建
private recreate_writer(): void {
if (this.stopped) {
return;
}
this.recreate_failures++;
if (this.recreate_failures >= Logger.MAX_RECREATE_FAILURES) {
this.stopped = true;
return;
}
try {
this.open_writer();
this.recreate_failures = 0;
this.flush();
} catch {
// 创建失败,排到下一次重试
this.schedule_recreate();
}
}
private format_day(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 当天首个文件为 year-month-day_log.log,超出大小后追加序号
private build_filename(day: string, index: number): string {
return index === 0 ? `${day}_log.log` : `${day}_log_${index}.log`;
}
// 打开(或创建)当前应写入的文件,并接管已有大小
private open_writer(): void {
if (!fs.existsSync(this.log_dir)) {
fs.mkdirSync(this.log_dir, { recursive: true });
}
const filename = this.build_filename(this.day, this.index);
const file_path = path.join(this.log_dir, filename);
try {
this.size = fs.statSync(file_path).size;
} catch {
this.size = 0;
}
const broken = this.writer;
if (broken) {
try {
broken.destroy();
} catch {
// 忽略已销毁流抛出的异常
}
}
this.writer = fs.createWriteStream(file_path, {flags: 'a'});
this.bind_writer_error();
this.prune_files(filename);
}
// 超出保留数量时删除最旧的日志文件(文件名按时间递增,保留当前文件)
private prune_files(keep_filename: string): void {
let entries: string[];
try {
entries = fs.readdirSync(this.log_dir)
.filter(name => Logger.LOG_FILE_REGEX.test(name))
.sort();
} catch {
return;
}
let excess = entries.length - Logger.MAX_FILES;
for (const name of entries) {
if (excess <= 0) break;
if (name === keep_filename) continue;
try {
fs.rmSync(path.join(this.log_dir, name));
excess--;
} catch {
// 删除失败则跳过,保留文件
}
}
}
// 跨天或写满时切换到新文件
private ensure_writer(): void {
const today = this.format_day(new Date());
if (today !== this.day) {
this.day = today;
this.index = 0;
this.open_writer();
return;
}
if (this.size >= Logger.MAX_FILE_SIZE) {
this.index++;
this.open_writer();
}
}
public info(message: any): log { // 白色
return {
message,
font_color: '#ffffff',
font_style: ['normal']
};
}
public success(message: any): log { // 柔和绿
return {
message,
font_color: '#8fb98f',
font_style: ['normal']
};
}
public warning(message: any): log { // 柔和沙黄
return {
message,
font_color: '#d1b478',
font_style: ['normal']
};
}
public error(message: any): log { // 柔和玫红
return {
message,
font_color: '#cf8e8e',
font_style: ['normal']
};
}
public tip(message: any): log { // 柔和钢蓝
return {
message,
font_color: '#8fb0c9',
font_style: ['normal']
};
}
public custom(message: any, font_color: string, font_style: font_style[]): log {
return {
message,
font_color,
font_style
};
}
public log(log: log[]): void {
if (this.stopped || log.length === 0) {
return;
}
const data = {
id: randomUUID(),
timestamp: Date.now(),
log
}
this.write_queue.push(`${JSON.stringify(data)}\n`);
this.flush();
}
// 串行写入:上一条落盘后才写下一批,保证大量日志的顺序
private flush(): void {
if (this.writing || this.stopped) {
return;
}
const chunk = this.write_queue.shift();
if (chunk === undefined) {
return;
}
this.ensure_writer();
this.writing = true;
this.in_flight_chunk = chunk;
this.writer.write(chunk, (err) => {
if (this.in_flight_chunk === chunk) this.in_flight_chunk = undefined;
this.writing = false;
if (err) {
if (this.stopped) return;
// 写入失败则回队,等待重建写入流后重试,避免丢日志
this.write_queue.unshift(chunk);
return;
}
this.size += Buffer.byteLength(chunk);
this.flush();
});
}
public close(): Promise<void> {
if (this.close_task) return this.close_task;
this.stopped = true;
if (this.recreate_timer) clearTimeout(this.recreate_timer);
this.recreate_timer = null;
this.close_task = new Promise<void>(resolve => {
if (this.writer.closed) {
resolve();
return;
}
this.writer.once('close', () => {
this.write_queue.length = 0;
resolve();
});
this.writer.end(this.write_queue.join(''));
});
return this.close_task;
}
private static parse_history_record(line: string): LogHistoryRecord | undefined {
try {
const record = JSON.parse(line);
if (record && typeof record.id === 'string' &&
typeof record.timestamp === 'number' && Number.isFinite(record.timestamp) &&
Array.isArray(record.log)) {
return record;
}
} catch {
// Ignore corrupt or incomplete records.
}
return undefined;
}
public static async read_recent_log(log_dir: string, count: number): Promise<LogHistoryRecord[]> {
if (!Number.isFinite(count) || count < 1) return [];
count = Math.floor(count);
let names: string[];
try {
names = (await fs.promises.readdir(log_dir))
.filter(name => Logger.LOG_FILE_REGEX.test(name))
.sort((a, b) => {
const date_order = a.slice(0, 10).localeCompare(b.slice(0, 10));
return date_order || Number(a.match(/_(\d+)\.log$/)?.[1] ?? 0) -
Number(b.match(/_(\d+)\.log$/)?.[1] ?? 0);
}).reverse();
} catch {
return [];
}
const records: LogHistoryRecord[] = [];
const max_line_bytes = 1024 * 1024;
let budget = 16 * max_line_bytes;
for (const name of names) {
if (records.length >= count || budget <= 0) break;
let file: fs.promises.FileHandle | undefined;
try {
file = await fs.promises.open(path.join(log_dir, name), 'r');
let position = (await file.stat()).size;
let pending = Buffer.alloc(0);
let skipping = false;
const accept = (line: Buffer) => {
if (line.length > max_line_bytes) return;
const record = Logger.parse_history_record(line.toString('utf8'));
if (record) records.push(record);
};
while (position > 0 && records.length < count && budget > 0) {
const length = Math.min(position, 64 * 1024, budget);
position -= length;
budget -= length;
const chunk = Buffer.allocUnsafe(length);
const {bytesRead} = await file.read(chunk, 0, length, position);
const data = Buffer.concat([chunk.subarray(0, bytesRead), pending]);
let end = data.length;
let newline: number;
while ((newline = data.lastIndexOf(10, end - 1)) >= 0 && end > 0) {
if (!skipping) accept(data.subarray(newline + 1, end));
skipping = false;
end = newline;
if (records.length >= count) break;
}
// Discard oversized lines until their preceding newline is found.
if (skipping || end > max_line_bytes) {
pending = Buffer.alloc(0);
skipping = true;
} else {
pending = Buffer.from(data.subarray(0, end));
}
}
if (position === 0 && !skipping && records.length < count) accept(pending);
} catch {
// Rotation or removal can race a history read; continue with older files.
} finally {
if (file) await file.close();
}
}
return records.reverse();
}
public async read_recent_log(count: number): Promise<LogHistoryRecord[]> {
if (!Number.isFinite(count) || count < 1) return [];
count = Math.floor(count);
const snapshot = [this.in_flight_chunk, ...this.write_queue]
.filter((chunk): chunk is string => chunk !== undefined)
.map(chunk => Logger.parse_history_record(chunk))
.filter((record): record is LogHistoryRecord => record !== undefined);
const snapshot_ids = new Set(snapshot.map(record => record.id));
const records = (await Logger.read_recent_log(this.log_dir, count))
.filter(record => !snapshot_ids.has(record.id));
const ids = new Set(records.map(record => record.id));
for (const record of snapshot) {
if (ids.has(record.id)) continue;
ids.add(record.id);
records.push(record);
}
return records.slice(-count);
}
// 读取最近的 count 条日志,跨文件时自动按时间顺序拼接
public get_recent_log(count: number): log[][] {
if (count <= 0) {
return [];
}
let names: string[];
try {
names = fs.readdirSync(this.log_dir)
.filter(name => Logger.LOG_FILE_REGEX.test(name))
.sort()
.reverse();
} catch {
return [];
}
const lines: string[] = [];
for (const name of names) {
let content: string;
try {
content = fs.readFileSync(path.join(this.log_dir, name), 'utf-8');
} catch {
continue;
}
const file_lines = content.split('\n').filter(line => line.length > 0);
for (let i = file_lines.length - 1; i >= 0 && lines.length < count; i--) {
lines.push(file_lines[i]);
}
if (lines.length >= count) {
break;
}
}
// 收集顺序为从新到旧,反转恢复为时间正序
lines.reverse();
return lines.map(line => {
try {
return JSON.parse(line);
} catch {
return line;
}
});
}
}