@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
JavaScript
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;
}