import "server-only"; import { randomUUID } from "node:crypto"; import { appendFile, mkdir, rename, stat } from "node:fs/promises"; import path from "node:path"; import { serverEnv } from "@/lib/server/env"; import type { LogLevel, LogLayer, LogStatus, StructuredLogEntry, } from "@/lib/shared/contracts/logging"; type ServerLogInput = { level?: LogLevel; layer: LogLayer; action: string; status?: LogStatus; durationMs?: number; requestId?: string; message?: string; meta?: Record; error?: unknown; runtime?: "server" | "client"; }; type ServerTimingOptions = { level?: LogLevel; layer: LogLayer; action: string; requestId?: string; meta?: Record; onSuccessMeta?: (result: unknown) => Record | undefined; onErrorMeta?: (error: unknown) => Record | undefined; }; const levelRank: Record = { debug: 10, info: 20, warn: 30, error: 40, }; const ANSI = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m", gray: "\x1b[90m", } as const; // Lines logged together are appended together: one file write per burst, not // one open/write/close per line queued behind each other under load. The file // rotates past LOG_FILE_MAX_BYTES, keeping the previous one as .1, so it cannot // fill the disk of a small home server. const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; let writeQueue: Promise = Promise.resolve(); let pendingLines: string[] = []; let logFileBytes: number | null = null; let logDirectoryReady = false; function getServerLogLevel(): LogLevel { const raw = serverEnv.LOG_LEVEL; if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") { return raw; } return "info"; } function shouldLog(level: LogLevel) { return levelRank[level] >= levelRank[getServerLogLevel()]; } function canUseColor() { if (process.env.NO_COLOR === "1" || process.env.NO_COLOR === "true") return false; return Boolean(process.stdout?.isTTY); } function colorize(text: string, color: string) { if (!canUseColor()) return text; return `${color}${text}${ANSI.reset}`; } function safeStringify(value: unknown) { try { return JSON.stringify(value); } catch { return '"[unserializable]"'; } } function statusGlyph(entry: StructuredLogEntry) { if (entry.status === "success") return colorize("✓", ANSI.green); if (entry.status === "error" || entry.level === "error") return colorize("x", ANSI.red); if (entry.level === "warn") return colorize("!", ANSI.yellow); if (entry.status === "start") return colorize(">", ANSI.cyan); return colorize("•", ANSI.gray); } function formatConsoleLine(entry: StructuredLogEntry) { const duration = typeof entry.durationMs === "number" ? ` ${entry.durationMs}ms` : ""; const request = entry.requestId ? ` req=${entry.requestId}` : ""; const message = entry.message ? ` msg=${entry.message}` : ""; const meta = entry.meta ? ` meta=${safeStringify(entry.meta)}` : ""; const error = entry.error?.message ? ` err=${entry.error.message}` : ""; return `${statusGlyph(entry)} ${entry.timestamp} ${entry.layer}.${entry.action} ${entry.status.toUpperCase()}${duration}${request}${message}${meta}${error}`; } function serializeError(error: unknown) { if (!error) return undefined; if (error instanceof Error) { return { name: error.name, message: error.message, stack: error.stack, }; } return { message: typeof error === "string" ? error : JSON.stringify(error), }; } function getLogFilePath() { const filePath = serverEnv.LOG_FILE_PATH || "logs/home-server.log"; return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath); } async function ensureLogDirectory() { if (logDirectoryReady) return; await mkdir(path.dirname(getLogFilePath()), { recursive: true }); logDirectoryReady = true; } function writeConsole(entry: StructuredLogEntry) { const line = formatConsoleLine(entry); if (entry.level === "error") { console.error(line); return; } if (entry.level === "warn") { console.warn(line); return; } console.log(line); } async function readFileSize(filePath: string) { try { return (await stat(filePath)).size; } catch { return 0; } } async function flushPendingLines() { const chunk = pendingLines.join(""); pendingLines = []; const chunkBytes = Buffer.byteLength(chunk, "utf8"); const filePath = getLogFilePath(); await ensureLogDirectory(); logFileBytes ??= await readFileSize(filePath); if (logFileBytes > 0 && logFileBytes + chunkBytes > LOG_FILE_MAX_BYTES) { await rename(filePath, `${filePath}.1`); logFileBytes = 0; } await appendFile(filePath, chunk, "utf8"); logFileBytes += chunkBytes; } function reportFileWriteFailure(error: unknown) { // The size is unknown after a failed write or rotation: read it again next time. logFileBytes = null; const fallback: StructuredLogEntry = { timestamp: new Date().toISOString(), runtime: "server", level: "error", layer: "system", action: "log.file.write", status: "error", message: "Failed writing structured log to file", error: serializeError(error), }; console.error(JSON.stringify(fallback)); } function writeToFile(entry: StructuredLogEntry) { if (process.env.NODE_ENV === "test") return; if (serverEnv.LOG_TO_FILE === false) return; pendingLines.push(`${JSON.stringify(entry)}\n`); // The first line of a batch schedules its flush; later ones join the batch. if (pendingLines.length === 1) { writeQueue = writeQueue.then(flushPendingLines).catch(reportFileWriteFailure); } } export function logServerAction(input: ServerLogInput) { const level = input.level ?? "info"; if (!shouldLog(level)) return; const entry: StructuredLogEntry = { timestamp: new Date().toISOString(), runtime: input.runtime ?? "server", level, layer: input.layer, action: input.action, status: input.status ?? "info", durationMs: input.durationMs, requestId: input.requestId, message: input.message, meta: input.meta, error: serializeError(input.error), }; writeConsole(entry); writeToFile(entry); } export async function withServerTiming( options: ServerTimingOptions, fn: () => Promise, ): Promise { const startedAt = performance.now(); logServerAction({ level: options.level ?? "info", layer: options.layer, action: options.action, status: "start", requestId: options.requestId, meta: options.meta, }); try { const result = await fn(); logServerAction({ level: options.level ?? "info", layer: options.layer, action: options.action, status: "success", requestId: options.requestId, durationMs: Number((performance.now() - startedAt).toFixed(2)), meta: { ...options.meta, ...(options.onSuccessMeta ? options.onSuccessMeta(result) : {}), }, }); return result; } catch (error) { logServerAction({ level: "error", layer: options.layer, action: options.action, status: "error", requestId: options.requestId, durationMs: Number((performance.now() - startedAt).toFixed(2)), meta: { ...options.meta, ...(options.onErrorMeta ? options.onErrorMeta(error) : {}), }, error, }); throw error; } } export function createRequestId() { return randomUUID(); } export async function flushServerLogsForTests() { await writeQueue; }