UNPKG

@reliverse/relinka

Version:

@reliverse/relinka is a modern, minimal logging library that actually feels right. It's not just pretty output — it's a system: smart formatting, file-safe logging, runtime config support, and a fatal mode built for developers who care about correctness.

615 lines (614 loc) 19.6 kB
import path from "@reliverse/pathkit"; import { re } from "@reliverse/relico"; import fs from "@reliverse/relifso"; import { loadConfig } from "c12"; import os from "node:os"; const ENABLE_DEV_DEBUG = false; const EXIT_GUARD = Symbol.for("relinka.exitHandlersRegistered"); const DEFAULT_RELINKA_CONFIG = { verbose: false, dirs: { dailyLogs: false, logDir: "logs", maxLogFiles: 0, specialDirs: { distDirNames: ["dist", "dist-jsr", "dist-npm", "dist-libs"], useParentConfigInDist: true } }, disableColors: false, logFilePath: "relinka.log", saveLogsToFile: false, timestamp: { enabled: false, format: "YYYY-MM-DD HH:mm:ss.SSS" }, cleanupInterval: 1e4, // 10 seconds bufferSize: 4096, // 4KB maxBufferAge: 5e3, // 5 seconds // A default set of levels, with fallback symbols for non-Unicode terminals: levels: { success: { symbol: "\u2713", fallbackSymbol: "[OK]", color: "greenBright", spacing: 3 }, info: { symbol: "\u25C8", fallbackSymbol: "[i]", color: "cyanBright", spacing: 3 }, error: { symbol: "\u2716", fallbackSymbol: "[ERR]", color: "redBright", spacing: 3 }, warn: { symbol: "\u26A0", fallbackSymbol: "[WARN]", color: "yellowBright", spacing: 3 }, fatal: { symbol: "\u203C", fallbackSymbol: "[FATAL]", color: "redBright", spacing: 3 }, verbose: { symbol: "\u2731", fallbackSymbol: "[VERBOSE]", color: "gray", spacing: 3 }, internal: { symbol: "\u2699", fallbackSymbol: "[INTERNAL]", color: "magentaBright", spacing: 3 }, log: { symbol: "\u2502", fallbackSymbol: "|", color: "dim", spacing: 3 }, null: { symbol: "", fallbackSymbol: "", color: "dim", spacing: 0 } } }; function isUnicodeSupported() { if (process.env.TERM_PROGRAM === "vscode" || process.env.WT_SESSION || process.env.TERM_PROGRAM === "iTerm.app" || process.env.TERM_PROGRAM === "hyper" || process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm" || process.env.ConEmuTask === "{cmd::Cmder}" || process.env.TERM === "xterm-256color") { return true; } if (process.platform === "win32") { const osRelease = os.release(); const match = /(\d+)\.(\d+)/.exec(osRelease); if (match && Number.parseInt(match[1], 10) >= 10) { return true; } if (process.env.TERM_PROGRAM === "mintty") { return true; } return false; } return true; } let currentConfig = { ...DEFAULT_RELINKA_CONFIG }; let isConfigInitialized = false; let resolveRelinkaConfig; export const relinkaConfig = new Promise((res) => { resolveRelinkaConfig = res; }); const logBuffers = /* @__PURE__ */ new Map(); const activeTimers = []; let bufferFlushTimer = null; let lastCleanupTime = 0; let cleanupScheduled = false; async function initializeConfig() { try { const result = await loadConfig({ name: "relinka", cwd: process.cwd(), dotenv: false, packageJson: false, rcFile: false, globalRc: false, defaults: DEFAULT_RELINKA_CONFIG }); currentConfig = result.config; isConfigInitialized = true; resolveRelinkaConfig?.(currentConfig); resolveRelinkaConfig = void 0; if (ENABLE_DEV_DEBUG) { console.log("[Dev Debug] Config file used:", result.configFile); console.log("[Dev Debug] All merged layers:", result.layers); console.log("[Dev Debug] Final configuration:", currentConfig); } } catch (err) { console.error( `[Relinka Config Error] Failed to load config: ${err instanceof Error ? err.message : String(err)}` ); currentConfig = { ...DEFAULT_RELINKA_CONFIG }; isConfigInitialized = true; resolveRelinkaConfig?.(currentConfig); resolveRelinkaConfig = void 0; } finally { setupBufferFlushTimer(); } } function setupBufferFlushTimer() { if (bufferFlushTimer) { clearInterval(bufferFlushTimer); activeTimers.splice(activeTimers.indexOf(bufferFlushTimer), 1); } const maxAge = getMaxBufferAge(currentConfig); bufferFlushTimer = setInterval(flushDueBuffers, Math.min(maxAge / 2, 2500)); bufferFlushTimer.unref(); activeTimers.push(bufferFlushTimer); function flushDueBuffers() { const now = Date.now(); for (const [fp, buf] of logBuffers) { if (buf.entries.length && now - buf.lastFlush >= maxAge) { flushLogBuffer(currentConfig, fp).catch(console.error); } } } } initializeConfig().catch((err) => { console.error( `[Relinka Config Error] Unhandled error: ${err instanceof Error ? err.message : String(err)}` ); if (!isConfigInitialized) { currentConfig = { ...DEFAULT_RELINKA_CONFIG }; isConfigInitialized = true; if (resolveRelinkaConfig) { resolveRelinkaConfig(currentConfig); resolveRelinkaConfig = void 0; } setupBufferFlushTimer(); } }); function isVerboseEnabled(config) { return config.verbose ?? DEFAULT_RELINKA_CONFIG.verbose; } function isColorEnabled(config) { return !(config.disableColors ?? DEFAULT_RELINKA_CONFIG.disableColors); } function getLogDir(config) { return config.dirs?.logDir ?? DEFAULT_RELINKA_CONFIG.dirs.logDir; } function isDailyLogsEnabled(config) { return config.dirs?.dailyLogs ?? DEFAULT_RELINKA_CONFIG.dirs.dailyLogs; } function shouldSaveLogs(config) { return config.saveLogsToFile ?? DEFAULT_RELINKA_CONFIG.saveLogsToFile; } function getMaxLogFiles(config) { return config.dirs?.maxLogFiles ?? DEFAULT_RELINKA_CONFIG.dirs.maxLogFiles; } function getBaseLogName(config) { return config.logFilePath ?? DEFAULT_RELINKA_CONFIG.logFilePath; } function getBufferSize(config) { return config.bufferSize ?? DEFAULT_RELINKA_CONFIG.bufferSize; } function getMaxBufferAge(config) { return config.maxBufferAge ?? DEFAULT_RELINKA_CONFIG.maxBufferAge; } function getCleanupInterval(config) { return config.cleanupInterval ?? DEFAULT_RELINKA_CONFIG.cleanupInterval; } function isDevEnv() { return process.env.NODE_ENV === "development"; } function getDateString(date = /* @__PURE__ */ new Date()) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String( date.getDate() ).padStart(2, "0")}`; } function getTimestamp(config) { if (!config.timestamp?.enabled) return ""; const now = /* @__PURE__ */ new Date(); const format = config.timestamp?.format || "YYYY-MM-DD HH:mm:ss.SSS"; return format.replace("YYYY", String(now.getFullYear())).replace("MM", String(now.getMonth() + 1).padStart(2, "0")).replace("DD", String(now.getDate()).padStart(2, "0")).replace("HH", String(now.getHours()).padStart(2, "0")).replace("mm", String(now.getMinutes()).padStart(2, "0")).replace("ss", String(now.getSeconds()).padStart(2, "0")).replace("SSS", String(now.getMilliseconds()).padStart(3, "0")); } function getLogFilePath(config) { const logDir = getLogDir(config); const daily = isDailyLogsEnabled(config); let finalLogName = getBaseLogName(config); if (daily) { const datePrefix = `${getDateString()}-`; finalLogName = datePrefix + finalLogName; } if (finalLogName && !finalLogName.endsWith(".log")) { finalLogName += ".log"; } const effectiveLogName = finalLogName || "relinka.log"; return path.resolve(process.cwd(), logDir, effectiveLogName); } function getLevelStyle(config, level) { const allLevels = config.levels || DEFAULT_RELINKA_CONFIG.levels || {}; const levelConfig = allLevels[level]; if (!levelConfig) { return { symbol: `[${level.toUpperCase()}]`, color: "dim", spacing: 3 }; } if (level === "null") { return { symbol: "", color: levelConfig.color, spacing: 0 }; } const { symbol, fallbackSymbol, color, spacing } = levelConfig; const effectiveSymbol = isUnicodeSupported() ? symbol : fallbackSymbol || `[${level.toUpperCase()}]`; return { symbol: effectiveSymbol, color, spacing: spacing ?? 3 }; } function formatDetails(details) { if (details === void 0) return ""; if (details instanceof Error) { return ` Stack Trace: ${details.stack || details.message}`; } if (typeof details === "object" && details !== null) { try { return ` ${JSON.stringify(details, null, 2)}`; } catch { return " [object Object]"; } } return ` ${String(details)}`; } function formatLogMessage(config, level, msg, details) { const timestamp = getTimestamp(config); const detailsStr = formatDetails(details); const { symbol, spacing } = getLevelStyle(config, level); const symbolWithSpaces = symbol ? `${symbol}${" ".repeat(spacing)}` : ""; const prefix = timestamp ? `[${timestamp}] ` : ""; return `${prefix}${symbolWithSpaces}${msg}${detailsStr}`; } const consoleMethodMap = { error: console.error, fatal: console.error, warn: console.warn, info: console.info, success: console.log, verbose: console.log, log: console.log, null: console.log }; function logToConsole(config, level, formattedMessage) { if (!isColorEnabled(config)) { const method2 = consoleMethodMap[level] || console.log; method2(formattedMessage); return; } const { color } = getLevelStyle(config, level); const colorFn = re[color] || re.dim; const method = consoleMethodMap[level] || console.log; method(`${colorFn(formattedMessage)}\x1B[0m`); } async function getLogFilesSortedByDate(config) { const logDirectoryPath = path.resolve(process.cwd(), getLogDir(config)); try { if (!await fs.pathExists(logDirectoryPath)) { if (ENABLE_DEV_DEBUG) { console.log(`[Dev Debug] Log directory not found: ${logDirectoryPath}`); } return []; } const files = await fs.readdir(logDirectoryPath); const logFiles = files.filter((f) => f.endsWith(".log")); if (logFiles.length === 0) return []; const fileInfoPromises = logFiles.map( async (fileName) => { const filePath = path.join(logDirectoryPath, fileName); try { const stats = await fs.stat(filePath); if (stats.isFile()) { return { path: filePath, mtime: stats.mtime.getTime() }; } return null; } catch (err) { if (isVerboseEnabled(config)) { console.error( `[Relinka FS Debug] Error reading stats for ${filePath}: ${err instanceof Error ? err.message : String(err)}` ); } return null; } } ); const logFileInfos = (await Promise.all(fileInfoPromises)).filter(Boolean); return logFileInfos.sort((a, b) => b.mtime - a.mtime); } catch (readErr) { if (isVerboseEnabled(config)) { console.error( `[Relinka FS Error] Failed reading log directory ${logDirectoryPath}: ${readErr instanceof Error ? readErr.message : String(readErr)}` ); } return []; } } async function deleteFiles(filePaths, config) { if (filePaths.length === 0) return; const results = await Promise.allSettled( filePaths.map((filePath) => fs.unlink(filePath)) ); const errors = results.map( (result, index) => result.status === "rejected" ? { path: filePaths[index], error: result.reason } : null ).filter(Boolean); if (errors.length > 0 && isVerboseEnabled(config)) { console.error( `[Relinka FS Error] Failed to delete ${errors.length} log files:`, errors.map( (e) => `${e?.path}: ${e?.error instanceof Error ? e.error.message : String(e?.error)}` ).join(", ") ); } } let sigintHandler; let sigtermHandler; export async function relinkaShutdown() { activeTimers.forEach((timer) => clearTimeout(timer)); activeTimers.length = 0; cleanupScheduled = false; if (sigintHandler) process.off("SIGINT", sigintHandler); if (sigtermHandler) process.off("SIGTERM", sigtermHandler); await flushAllLogBuffers(); } async function cleanupOldLogFiles(config) { const maxFiles = getMaxLogFiles(config); const cleanupInterval = getCleanupInterval(config); if (!shouldSaveLogs(config) || maxFiles <= 0) return; const now = Date.now(); if (now - lastCleanupTime < cleanupInterval) { if (!cleanupScheduled) { cleanupScheduled = true; const delay = cleanupInterval - (now - lastCleanupTime); const timer = setTimeout(() => { cleanupScheduled = false; lastCleanupTime = Date.now(); const index = activeTimers.indexOf(timer); if (index !== -1) activeTimers.splice(index, 1); cleanupOldLogFiles(config).catch((err) => { if (isVerboseEnabled(config)) { console.error( `[Relinka Delayed Cleanup Error] ${err instanceof Error ? err.message : String(err)}` ); } }); }, delay); timer.unref(); activeTimers.push(timer); } return; } lastCleanupTime = now; try { const sortedLogFiles = await getLogFilesSortedByDate(config); if (sortedLogFiles.length > maxFiles) { const filesToDelete = sortedLogFiles.slice(maxFiles).map((f) => f.path); if (filesToDelete.length > 0) { await deleteFiles(filesToDelete, config); if (isVerboseEnabled(config)) { console.log( `[Relinka Cleanup] Deleted ${filesToDelete.length} old log file(s). Kept ${maxFiles}.` ); } } } } catch (err) { if (isVerboseEnabled(config)) { console.error( `[Relinka Cleanup Error] Failed during log cleanup: ${err instanceof Error ? err.message : String(err)}` ); } } } async function appendToLogFileImmediate(config, absoluteLogFilePath, logMessage) { try { await fs.ensureDir(path.dirname(absoluteLogFilePath)); await fs.appendFile(absoluteLogFilePath, `${logMessage} `); } catch (err) { if (isVerboseEnabled(config)) { console.error( `[Relinka File Error] Failed to write to log file ${absoluteLogFilePath}: ${err instanceof Error ? err.message : String(err)}` ); } } } let logWriteChain = Promise.resolve(); function addToLogBuffer(config, filePath, message) { const bufferSize = getBufferSize(config); let buffer = logBuffers.get(filePath); if (!buffer) { buffer = { filePath, entries: [], size: 0, lastFlush: Date.now() }; logBuffers.set(filePath, buffer); } buffer.entries.push(message); buffer.size += message.length + 1; if (buffer.size >= bufferSize) { return flushLogBuffer(config, filePath); } return Promise.resolve(); } function flushLogBuffer(config, filePath) { const buffer = logBuffers.get(filePath); if (!buffer || buffer.entries.length === 0) { return Promise.resolve(); } const content = `${buffer.entries.join("\n")} `; buffer.entries = []; buffer.size = 0; buffer.lastFlush = Date.now(); logWriteChain = logWriteChain.then(() => { return appendToLogFileImmediate(config, filePath, content); }).catch((err) => { if (isVerboseEnabled(config)) { console.error( `[Relinka Buffer Flush Error] Failed to flush buffer for ${filePath}: ${err instanceof Error ? err.message : String(err)}` ); } }); return logWriteChain; } function queueLogWrite(config, absoluteLogFilePath, logMessage) { return addToLogBuffer(config, absoluteLogFilePath, logMessage); } export async function flushAllLogBuffers() { const filePaths = Array.from(logBuffers.keys()); await Promise.all( filePaths.map((path2) => flushLogBuffer(currentConfig, path2)) ); } function internalFatalLogAndThrow(message, ...args) { const formatted = formatLogMessage(currentConfig, "fatal", message, args); logToConsole(currentConfig, "fatal", formatted); if (shouldSaveLogs(currentConfig)) { try { const absoluteLogFilePath = getLogFilePath(currentConfig); fs.ensureDirSync(path.dirname(absoluteLogFilePath)); fs.appendFileSync(absoluteLogFilePath, `${formatted} `); } catch (err) { console.error( `[Relinka Fatal File Error] Failed to write fatal error: ${err instanceof Error ? err.message : String(err)}` ); } } if (isDevEnv()) { debugger; } throw new Error(`Fatal error: ${message}`); } export function shouldNeverHappen(message, ...args) { return internalFatalLogAndThrow(message, ...args); } export function truncateString(msg, maxLength = 100) { if (!msg || msg.length <= maxLength) return msg; return `${msg.slice(0, maxLength - 1)}\u2026`; } export function casesHandled(unexpectedCase) { debugger; throw new Error( `A case was not handled for value: ${truncateString(String(unexpectedCase ?? "unknown"))}` ); } function registerExitHandlers() { if (globalThis[EXIT_GUARD]) return; globalThis[EXIT_GUARD] = true; process.once("beforeExit", () => { void flushAllLogBuffers(); }); sigintHandler = () => { void flushAllLogBuffers().finally(() => process.exit(0)); }; sigtermHandler = () => { void flushAllLogBuffers().finally(() => process.exit(0)); }; process.once("SIGINT", sigintHandler); process.once("SIGTERM", sigtermHandler); } registerExitHandlers(); export function relinka(type, message, ...args) { if (type === "clear") { console.clear(); return; } if (message === "") { console.log(); return; } const levelName = type.toLowerCase(); if (levelName === "fatal") { return internalFatalLogAndThrow(message, ...args); } if (levelName === "verbose" && !isVerboseEnabled(currentConfig)) { return; } const details = args.length > 1 ? args : args[0]; const formatted = formatLogMessage( currentConfig, levelName, message, details ); logToConsole(currentConfig, levelName, formatted); if (shouldSaveLogs(currentConfig) && levelName !== "fatal") { const absoluteLogFilePath = getLogFilePath(currentConfig); queueLogWrite(currentConfig, absoluteLogFilePath, formatted).catch( (err) => { if (isVerboseEnabled(currentConfig)) { console.error( `[Relinka File Error] Failed to write log line: ${err instanceof Error ? err.message : String(err)}` ); } } ); if (getMaxLogFiles(currentConfig) > 0) { cleanupOldLogFiles(currentConfig).catch((err) => { if (isVerboseEnabled(currentConfig)) { console.error( `[Relinka Cleanup Error] ${err instanceof Error ? err.message : String(err)}` ); } }); } } } export async function relinkaAsync(type, message, ...args) { if (message === "") { console.log(); return; } await relinkaConfig; const levelName = type.toLowerCase(); if (levelName === "fatal") { shouldNeverHappen(message, ...args); } if (levelName === "verbose" && !isVerboseEnabled(currentConfig)) { return; } const details = args.length > 1 ? args : args[0]; const formatted = formatLogMessage( currentConfig, levelName, message, details ); logToConsole(currentConfig, levelName, formatted); if (shouldSaveLogs(currentConfig)) { const absoluteLogFilePath = getLogFilePath(currentConfig); try { await queueLogWrite(currentConfig, absoluteLogFilePath, formatted); if (getMaxLogFiles(currentConfig) > 0) { await cleanupOldLogFiles(currentConfig); } } catch (err) { if (isVerboseEnabled(currentConfig)) { console.error( `[Relinka File Async Error] Error during file logging/cleanup: ${err instanceof Error ? err.message : String(err)}` ); } } } } export function defineConfig(config) { return config; }