@anteros/core 0.0.25 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
5
+
6
+ const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
7
+
8
+ /** ANSI colors (no dependency on @colors/colors import order) */
9
+ const CONSOLE_COLORS: Record<LogLevel, string> = {
10
+ debug: '\x1b[90m', // gray
11
+ info: '\x1b[36m', // cyan
12
+ warn: '\x1b[33m', // yellow
13
+ error: '\x1b[31m', // red
14
+ };
15
+ const RESET = '\x1b[0m';
16
+
17
+ export type LoggerOptions = {
18
+ /** Minimum level to emit (default: 'info') */
19
+ level?: LogLevel;
20
+ /** Also write to the console (default: true) */
21
+ console?: boolean;
22
+ /** File logging: `true` → `<dir>/anteros.log`, `string` → custom path, `false` → disabled (default: true) */
23
+ file?: boolean | string;
24
+ /** Directory used when `file` is `true` (default: '.logs') */
25
+ dir?: string;
26
+ /** Rotate when the file exceeds this size in bytes (default: 10MB) */
27
+ maxSize?: number;
28
+ /** Keep this many rotated files (default: 5) */
29
+ maxFiles?: number;
30
+ };
31
+
32
+ const DEFAULT_OPTIONS: Required<Pick<LoggerOptions, 'level' | 'console' | 'dir' | 'maxSize' | 'maxFiles'>> & { file: boolean | string } = {
33
+ level: 'info',
34
+ console: true,
35
+ file: true,
36
+ dir: '.logs',
37
+ maxSize: 10 * 1024 * 1024, // 10MB
38
+ maxFiles: 5,
39
+ };
40
+
41
+ function safeStringify(value: unknown): string {
42
+ if (value === undefined) return '';
43
+ try {
44
+ const seen = new WeakSet();
45
+ return JSON.stringify(value, (_key, v) => {
46
+ if (typeof v === 'bigint') return String(v);
47
+ if (v && typeof v === 'object') {
48
+ if (seen.has(v)) return '[Circular]';
49
+ seen.add(v);
50
+ }
51
+ return v;
52
+ });
53
+ } catch {
54
+ return String(value);
55
+ }
56
+ }
57
+
58
+ class Logger {
59
+ #options: LoggerOptions & typeof DEFAULT_OPTIONS;
60
+ #filePath: string = '';
61
+ #size = 0;
62
+ #pending: Promise<void>[] = [];
63
+ #queue: Promise<void> = Promise.resolve();
64
+
65
+ constructor(options: LoggerOptions = {}) {
66
+ this.#options = { ...DEFAULT_OPTIONS, ...options };
67
+ }
68
+
69
+ /** Serialize file operations (appends + renames) — preserves order, no races */
70
+ #enqueue(fn: () => Promise<void>) {
71
+ this.#queue = this.#queue.then(fn).catch(() => {});
72
+ this.#pending.push(this.#queue);
73
+ }
74
+
75
+ /** (Re)configure — each call is independent: unspecified options reset to defaults */
76
+ configure(options: LoggerOptions = {}) {
77
+ this.#options = { ...DEFAULT_OPTIONS, ...options };
78
+ this.#openFile();
79
+ }
80
+
81
+ #resolvePath(): string {
82
+ const file = this.#options.file;
83
+ if (typeof file === 'string') return file;
84
+ const dir = this.#options.dir;
85
+ // Keep relative dirs explicit (`./logs`), but never double-prefix
86
+ // dot-dirs (`.logs`, `./logs`, `../logs`) or absolute paths
87
+ const prefix = /^(\/|\.|[A-Za-z]:)/.test(dir) ? dir : `./${dir}`;
88
+ return `${prefix}/anteros.log`;
89
+ }
90
+
91
+ #openFile() {
92
+ if (!this.#options.file) {
93
+ this.#filePath = '';
94
+ this.#size = 0;
95
+ return;
96
+ }
97
+ this.#filePath = this.#resolvePath();
98
+ fs.mkdirSync(path.dirname(this.#filePath), { recursive: true });
99
+ try {
100
+ this.#size = fs.statSync(this.#filePath).size;
101
+ } catch {
102
+ this.#size = 0;
103
+ }
104
+ }
105
+
106
+ #rotate(filePath: string) {
107
+ const { maxFiles } = this.#options;
108
+ this.#enqueue(async () => {
109
+ const dir = path.dirname(filePath);
110
+ const ext = path.extname(filePath);
111
+ const base = path.basename(filePath, ext);
112
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
114
+
115
+ // Dated rotation name: anteros-2026-07-08-1.log, -2, … (index resets per day)
116
+ let n = 1;
117
+ try {
118
+ const files = await fs.promises.readdir(dir);
119
+ const re = new RegExp(`^${esc(base)}-${today}-(\\d+)${esc(ext)}$`);
120
+ for (const f of files) {
121
+ const m = f.match(re);
122
+ if (m) n = Math.max(n, parseInt(m[1]!, 10) + 1);
123
+ }
124
+ } catch {}
125
+ await fs.promises.rename(filePath, path.join(dir, `${base}-${today}-${n}${ext}`)).catch(() => {});
126
+
127
+ // Prune: keep only the maxFiles most recent rotated files
128
+ try {
129
+ const re = new RegExp(`^${esc(base)}-\\d{4}-\\d{2}-\\d{2}-\\d+${esc(ext)}$`);
130
+ const files = (await fs.promises.readdir(dir)).filter((f) => re.test(f));
131
+ const withTime = await Promise.all(
132
+ files.map(async (f) => ({ f, t: (await fs.promises.stat(path.join(dir, f))).mtimeMs }))
133
+ );
134
+ withTime.sort((a, b) => b.t - a.t);
135
+ for (const { f } of withTime.slice(maxFiles)) {
136
+ await fs.promises.unlink(path.join(dir, f)).catch(() => {});
137
+ }
138
+ } catch {}
139
+ });
140
+ }
141
+
142
+ #write(level: LogLevel, msg: string, meta?: unknown, opts: { console?: boolean } = {}) {
143
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.#options.level]) return;
144
+
145
+ const tsLocal = new Date().toLocaleTimeString('en-GB', { hour12: false });
146
+ const ts = Date.now() / 1000;
147
+ const pad = level.toUpperCase().padEnd(5);
148
+ const isPlain = (v: any): boolean =>
149
+ v !== null && typeof v === 'object' && !Array.isArray(v)
150
+ && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
151
+
152
+ // JSONL payload (file): Caddy-style envelope + user meta (user keys win)
153
+ const payload: Record<string, unknown> = { ts, level, msg };
154
+ if (isPlain(meta)) {
155
+ Object.assign(payload, meta);
156
+ } else if (meta !== undefined && meta !== null) {
157
+ payload.value = meta;
158
+ }
159
+
160
+ // Console (human-readable, colorized) — skipped when opts.console === false
161
+ if (this.#options.console && opts.console !== false) {
162
+ const metaStr = safeStringify(meta);
163
+ const color = CONSOLE_COLORS[level];
164
+ const line = `[${tsLocal}] ${color}${pad}${RESET} ${msg}${metaStr ? ` ${metaStr}` : ''}`;
165
+ if (level === 'error') console.error(line);
166
+ else if (level === 'warn') console.warn(line);
167
+ else console.log(line);
168
+ }
169
+
170
+ // File: pure JSONL — one JSON object per line
171
+ if (!this.#filePath) return;
172
+ const line = safeStringify(payload) + '\n';
173
+ const bytes = Buffer.byteLength(line);
174
+ const filePath = this.#filePath;
175
+ if (this.#size + bytes > this.#options.maxSize) {
176
+ this.#size = 0;
177
+ this.#rotate(filePath);
178
+ }
179
+ this.#size += bytes;
180
+ this.#enqueue(() => fs.promises.appendFile(filePath, line));
181
+ }
182
+
183
+ debug(msg: string, meta?: unknown) { this.#write('debug', msg, meta); }
184
+ info(msg: string, meta?: unknown) { this.#write('info', msg, meta); }
185
+ warn(msg: string, meta?: unknown) { this.#write('warn', msg, meta); }
186
+ error(msg: string, meta?: unknown) { this.#write('error', msg, meta); }
187
+
188
+ /** Write to the file only (no console output) — for events that would clutter the terminal */
189
+ file(msg: string, meta?: unknown) { this.#write('info', msg, meta, { console: false }); }
190
+
191
+ /** Current log file path (empty when file logging is disabled) */
192
+ get filePath(): string { return this.#filePath; }
193
+
194
+ /** Wait until every pending write is on disk */
195
+ async flush(): Promise<void> {
196
+ while (this.#pending.length) {
197
+ const batch = this.#pending;
198
+ this.#pending = [];
199
+ await Promise.all(batch);
200
+ }
201
+ }
202
+
203
+ /** Fire-and-forget flush (used at shutdown) */
204
+ close() {
205
+ void this.flush();
206
+ }
207
+ }
208
+
209
+ /** Singleton — console + file. Configured at boot from `cfg.server.logging`. */
210
+ export const logger = new Logger({ console: true, file: false });
211
+
212
+ export default logger;