echo/src/lib/file.ts
creations f313a8a329
All checks were successful
Code quality checks / biome (push) Successful in 9s
try fix for windows, change how logging works allows much more flexibility
2025-06-11 18:24:48 -04:00

158 lines
3.7 KiB
TypeScript

import {
type WriteStream,
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { serializeLogData } from "@lib/char";
import { format } from "date-fns-tz";
import type { LogLevel, LoggerConfig } from "@types";
class FileLogger {
private stream: WriteStream | null = null;
private filePath = "";
private date = "";
private fileNameFormat = "yyyy-MM-dd";
constructor(private readonly config: Required<LoggerConfig>) {
if (!existsSync(this.config.directory)) {
mkdirSync(this.config.directory, { recursive: true });
}
if (this.config.fileNameFormat) {
try {
format(new Date(), this.config.fileNameFormat, {
timeZone: this.config.timezone,
});
this.fileNameFormat = this.config.fileNameFormat;
} catch (error) {
throw new Error(
`[@atums/echo] Invalid fileNameFormat: ${this.config.fileNameFormat}. Error: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
}
private getLogFilePath(dateStr: string): string {
const fileName = `${dateStr}.jsonl`;
return join(this.config.directory, fileName);
}
private resetStream(path: string): void {
this.stream?.end();
this.stream = createWriteStream(path, { flags: "a", encoding: "utf-8" });
this.filePath = path;
}
private generateFileRegex(): RegExp {
const pattern = this.fileNameFormat
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\\y\\y\\y\\y/g, "\\d{4}")
.replace(/\\M\\M/g, "\\d{2}")
.replace(/\\d\\d/g, "\\d{2}")
.replace(/\\H\\H/g, "\\d{2}")
.replace(/\\m\\m/g, "\\d{2}")
.replace(/\\s\\s/g, "\\d{2}")
.replace(/\\S\\S\\S/g, "\\d{3}");
return new RegExp(`^${pattern}\\.jsonl$`);
}
private pruneOldLogs(): void {
if (this.config.maxFiles === null) {
return;
}
if (this.config.maxFiles < 1) {
throw new Error("[@atums/echo] maxFiles must be >= 1 if set.");
}
const fileRegex = this.generateFileRegex();
const files = readdirSync(this.config.directory)
.filter((file) => fileRegex.test(file))
.sort();
const excess = files.slice(
0,
Math.max(0, files.length - this.config.maxFiles),
);
for (const file of excess) {
try {
unlinkSync(join(this.config.directory, file));
} catch {}
}
}
private getFilePath(dateStr?: string): string {
if (this.config.rotate && dateStr) {
return this.getLogFilePath(dateStr);
}
return join(this.config.directory, "log.jsonl");
}
public write(
level: LogLevel | string,
data: unknown,
meta: {
id: string;
fileName: string;
line: string;
column: string;
timestamp: string;
prettyTimestamp: string;
},
): void {
if (this.config.disableFile) return;
const now = new Date();
const line = `${JSON.stringify({
timestamp: new Date(meta.timestamp).getTime(),
level,
id: meta.id,
file: meta.fileName,
line: meta.line,
column: meta.column,
data: serializeLogData(data),
})}\n`;
let path: string;
const dateStr = this.config.rotate
? format(now, this.fileNameFormat, { timeZone: this.config.timezone })
: undefined;
const needsRotation = this.config.rotate && this.date !== dateStr;
path = this.getFilePath(dateStr);
if (!this.stream || needsRotation || this.filePath !== path) {
if (this.config.rotate && dateStr) {
this.date = dateStr;
}
this.resetStream(path);
if (needsRotation) {
this.pruneOldLogs();
}
}
try {
this.stream?.write(line);
} catch (err) {
if (this.config.console) {
throw new Error(`[@atums/echo] Failed to write to log file: ${err}`);
}
}
}
public flush(): Promise<void> {
return new Promise((resolve) => {
this.stream?.end(resolve);
});
}
}
export { FileLogger };