diff --git a/README.md b/README.md index a6e2029..8714f5a 100644 --- a/README.md +++ b/README.md @@ -69,20 +69,43 @@ constructor > environment > logger.json > defaults ```json { - "directory": "logs", - "level": "debug", - "console": true, - "consoleColor": true, - "rotate": true, - "maxFiles": 3, - "prettyPrint": true, - "pattern": "{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} ({file-name}:{line}:{column}) {data}", - "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", - "customColors": { - "GET": "green", - "POST": "blue", - "DELETE": "red" - } + "directory": "logs", + "level": "debug", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{line}:{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "red", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true } ``` @@ -158,7 +181,7 @@ The output format is controlled by: ## Output Examples -### Console (with colors) +### Console ``` 2025-05-24 16:15:00.000 [INFO] (index.ts:3:6) Server started diff --git a/package.json b/package.json index 6b6e245..0238cd0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@atums/echo", - "version": "1.0.1", - "description": "", + "version": "1.0.2", + "description": "A minimal, flexible logger", "private": false, "type": "module", "main": "./dist/index.js", @@ -28,6 +28,7 @@ } }, "scripts": { + "dev": "bun run build --watch", "build": "rm -rf dist && tsup src/index.ts --dts --out-dir dist --format esm,cjs", "lint": "bunx biome check", "lint:fix": "bunx biome check --fix", diff --git a/src/index.ts b/src/index.ts index f1fe34f..a3b0719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,12 +16,13 @@ import { loadLoggerConfig, logLevelValues, } from "@lib/config"; -import { writeLogJson } from "@lib/file"; +import { FileLogger } from "@lib/file"; import type { LogLevel, LoggerConfig } from "@types"; class Echo { private readonly directory: string; private readonly config: Required; + private readonly fileLogger: FileLogger | null = null; constructor(config?: string | LoggerConfig) { const fileConfig: LoggerConfig = @@ -45,6 +46,8 @@ class Echo { if (!this.config.disableFile) { Echo.validateDirectory(this.directory); + + this.fileLogger = new FileLogger(this.config); } } @@ -61,14 +64,6 @@ class Echo { accessSync(dir, constants.W_OK); } - public getDirectory(): string { - return this.directory; - } - - public getConfig(): Required { - return this.config; - } - private log(level: LogLevel, data: unknown): void { if ( this.config.silent || @@ -85,8 +80,8 @@ class Echo { ); } - if (!this.config.disableFile) { - writeLogJson(level, data, meta, this.config); + if (!this.config.disableFile && this.fileLogger) { + this.fileLogger.write(level, data, meta); } } @@ -114,8 +109,8 @@ class Echo { this.log("trace", data); } - public custom(tag: string, context: string, message: unknown): void { - if (this.config.silent || !this.config.console) return; + public custom(tag: string, context: string, data: unknown): void { + if (this.config.silent) return; const timestamps = getTimestamp(this.config); @@ -128,14 +123,14 @@ class Echo { const reset = this.config.consoleColor ? ansiColors.reset : ""; const resolvedData = - this.config.prettyPrint && typeof message === "object" && message !== null - ? inspect(message, { + this.config.prettyPrint && typeof data === "object" && data !== null + ? inspect(data, { depth: null, colors: this.config.consoleColor, breakLength: 1, compact: false, }) - : format(message); + : format(data); const pattern = this.config.customPattern ?? @@ -152,9 +147,25 @@ class Echo { .replace(/{color:contextColor}/g, contextColor) .replace(/{reset}/g, reset); - console.log(line); + if (this.config.console) { + console.log(line); + } + + if (!this.config.disableFile && this.fileLogger) { + const meta = getCallerInfo(this.config); + this.fileLogger.write(tag, { context, data }, meta); + } + } + + public flush(): Promise { + return this.fileLogger?.flush() ?? Promise.resolve(); } } +function createLogger(config?: string | LoggerConfig): Echo { + return new Echo(config); +} + const echo = new Echo(); -export { echo, Echo }; +export { echo, Echo, createLogger }; +export type { LoggerConfig, LogLevel } from "@types"; diff --git a/src/lib/char.ts b/src/lib/char.ts index 7625656..ae29015 100644 --- a/src/lib/char.ts +++ b/src/lib/char.ts @@ -41,13 +41,14 @@ function getCallerInfo(config: Required): { } { const id = generateShortId(); + const timestampInfo = getTimestamp(config); const fallback = { - id: id, + id, fileName: "unknown", line: "0", - timestamp: getTimestamp(config).timestamp, - prettyTimestamp: getTimestamp(config).prettyTimestamp, column: "0", + timestamp: timestampInfo.timestamp, + prettyTimestamp: timestampInfo.prettyTimestamp, }; const stack = new Error().stack; @@ -73,8 +74,8 @@ function getCallerInfo(config: Required): { fileName: basename(fullPath), line: lineNumber, column: columnNumber, - timestamp: getTimestamp(config).timestamp, - prettyTimestamp: getTimestamp(config).prettyTimestamp, + timestamp: timestampInfo.timestamp, + prettyTimestamp: timestampInfo.prettyTimestamp, }; } @@ -97,8 +98,8 @@ function getCallerInfo(config: Required): { fileName: basename(fullPath), line: lineNumber, column: columnNumber, - timestamp: getTimestamp(config).timestamp, - prettyTimestamp: getTimestamp(config).prettyTimestamp, + timestamp: timestampInfo.timestamp, + prettyTimestamp: timestampInfo.prettyTimestamp, }; } } diff --git a/src/lib/file.ts b/src/lib/file.ts index 3eb8414..1a7e372 100644 --- a/src/lib/file.ts +++ b/src/lib/file.ts @@ -3,103 +3,124 @@ import { createWriteStream, existsSync, mkdirSync, + readdirSync, + unlinkSync, } from "node:fs"; import { join } from "node:path"; import type { LogLevel, LoggerConfig } from "@types"; import { format } from "date-fns-tz"; -let currentStream: WriteStream | null = null; -let currentFilePath = ""; -let currentDate = ""; +class FileLogger { + private stream: WriteStream | null = null; + private filePath = ""; + private date = ""; -function getLogFilePath( - config: Required, - dateStr: string, -): string { - return join(config.directory, `${dateStr}.jsonl`); -} - -function resetStream(path: string): void { - currentStream?.end(); - currentStream = createWriteStream(path, { flags: "a", encoding: "utf-8" }); - currentFilePath = path; -} - -export function writeLogJson( - level: LogLevel, - data: unknown, - meta: { - id: string; - fileName: string; - line: string; - column: string; - timestamp: string; - prettyTimestamp: string; - }, - config: Required, -): void { - if (config.disableFile) return; - - const now = new Date(); - - if (!existsSync(config.directory)) { - mkdirSync(config.directory, { recursive: true }); + constructor(private readonly config: Required) { + if (!existsSync(this.config.directory)) { + mkdirSync(this.config.directory, { recursive: true }); + } } - let filePath: string; + private getLogFilePath(dateStr: string): string { + return join(this.config.directory, `${dateStr}.jsonl`); + } - if (config.rotate) { - const dateStr = format(now, "yyyy-MM-dd", { - timeZone: config.timezone, + private resetStream(path: string): void { + this.stream?.end(); + this.stream = createWriteStream(path, { flags: "a", encoding: "utf-8" }); + this.filePath = path; + } + + private pruneOldLogs(): void { + if (this.config.maxFiles && this.config.maxFiles < 1) { + throw new Error("[@atums/echo] maxFiles must be >= 1 if set."); + } + + const files = readdirSync(this.config.directory) + .filter((file) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.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 {} + } + } + + 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(); + let path: string; + + if (this.config.rotate) { + const dateStr = format(now, "yyyy-MM-dd", { + timeZone: this.config.timezone, + }); + path = this.getLogFilePath(dateStr); + + if (!this.stream || this.filePath !== path || this.date !== dateStr) { + this.date = dateStr; + this.resetStream(path); + this.pruneOldLogs(); + } + } else { + path = join(this.config.directory, "log.jsonl"); + + if (!this.stream || this.filePath !== path) { + this.resetStream(path); + } + } + + const line = `${JSON.stringify({ + timestamp: new Date(meta.timestamp).getTime(), + level, + id: meta.id, + file: meta.fileName, + line: meta.line, + column: meta.column, + data: + data instanceof Error + ? { + name: data.name, + message: data.message, + stack: data.stack, + } + : typeof data === "string" || typeof data === "number" + ? data + : data, + })}\n`; + + 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 { + return new Promise((resolve) => { + this.stream?.end(resolve); }); - filePath = getLogFilePath(config, dateStr); - - if ( - currentStream === null || - currentFilePath !== filePath || - currentDate !== dateStr - ) { - currentDate = dateStr; - resetStream(filePath); - } - } else { - filePath = join(config.directory, "log.jsonl"); - - if (currentStream === null || currentFilePath !== filePath) { - resetStream(filePath); - } } - - const line = `${JSON.stringify({ - timestamp: new Date(meta.timestamp).getTime(), - level, - id: meta.id, - file: meta.fileName, - line: meta.line, - column: meta.column, - data: - data instanceof Error - ? { - name: data.name, - message: data.message, - stack: data.stack, - } - : typeof data === "string" || typeof data === "number" - ? data - : data, - })}\n`; - - if (currentStream === null) { - throw new Error("Logger stream is not initialized"); - } - - currentStream.write(line); } -process.on("exit", () => { - currentStream?.end(); -}); -process.on("SIGINT", () => { - currentStream?.end(); - process.exit(); -}); +export { FileLogger }; diff --git a/tsconfig.json b/tsconfig.json index 231a4a8..31ec442 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "moduleDetection": "force", "allowJs": true, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "allowImportingTsExtensions": false, "verbatimModuleSyntax": true, "noEmit": false, "declaration": true,