@adriangalilea/utils 2.30.0 → 2.31.0

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.
@@ -1,306 +1,295 @@
1
1
  /**
2
- * A Next.js-style logger for TypeScript applications
3
- * Provides colored output with Unicode symbols for different log levels
2
+ * Terminal-first logger with two renderings and one automatic decision.
4
3
  *
5
- * Known limitations:
6
- * - createLogger() is just a prefix wrapper, not a real scoped instance.
7
- * No per-scope level control (can't silence 'bor' but keep 'bdns' verbose).
8
- * - No withTag/child pattern — consumer can't control library log levels.
4
+ * Two knobs, never confusing: format = how lines render, time = whether
5
+ * lines carry time. Each defaults sensibly and independently.
9
6
  *
10
- * Candidate replacement: consola (unjs). Same DX goals, solves all the above:
11
- * - consola.level = N at runtime
12
- * - consola.withTag('bdns') for scoped loggers that inherit global level
13
- * - Pretty dev output, JSON in prod, browser support, 0 deps
14
- * - https://github.com/unjs/consola
7
+ * Format — LOG_FORMAT=human|record, or setLogFormat(). Default by TTY
8
+ * detection on the sink: a TTY gets human (symbols, color, a line meant
9
+ * for eyes), anything else gets record (level words, plain text, a line
10
+ * meant for a file read later). Detection happens on the stream actually
11
+ * written to (stderr), never a sibling stream.
15
12
  *
16
- * TODO: test consola in dokploy instance before migrating.
13
+ * Time — LOG_TIME=1|0, or setLogTime(). Default: on in Node record mode,
14
+ * off everywhere else (human eyes don't need stamps; browser and Worker
15
+ * consoles stamp lines themselves). Record time opens the line with UTC
16
+ * RFC3339; human time prefixes a dim local HH:MM:SS (a human knows
17
+ * today's date).
18
+ *
19
+ * The record line with time on, identical across go-utils and py-utils by
20
+ * design:
21
+ * 2026-08-07T12:34:56Z WARN [scope] message
22
+ * UTC RFC3339 seconds precision · level word padded to 5 · scope brackets
23
+ * only when scoped · no color, no symbols. Grep WARN, not ⚠.
24
+ *
25
+ * Sinks
26
+ * Node/Bun/Deno: every level writes to stderr through console.error, so
27
+ * stdout stays clean for data and cli/live's console routing keeps log
28
+ * lines above an active region.
29
+ * Browser: level-mapped console methods (debug/info/warn/error), human.
30
+ * Workers: level-mapped console methods, record.
31
+ *
32
+ * Levels vs verbs
33
+ * Levels filter: silent < error < warn < info < debug < trace. An unknown
34
+ * LOG_LEVEL is a misconfiguration and throws Panic.
35
+ * Verbs express outcome and are renderings, never levels:
36
+ * error ⨯ fail ⨯ (error) · warn ⚠ (warn) · info · success ✓ · wait ○ ·
37
+ * ready ▶ · step • (info) · debug ◦ (debug) · trace » (trace).
38
+ * In record mode a verb renders as its level word.
39
+ *
40
+ * Scopes
41
+ * log.scope('api') returns a child whose lines carry [api]; scopes nest
42
+ * and join: log.scope('api').scope('auth') → [api auth]. A scoped logger
43
+ * resolves its threshold from {SCOPE}_LOG_LEVEL (upper-cased, joined and
44
+ * sanitized to A-Z0-9_: API_AUTH_LOG_LEVEL, then API_LOG_LEVEL), falling
45
+ * back to setLogLevel(), then LOG_LEVEL, then info — read live, so one
46
+ * subsystem can be silenced or opened up without touching code.
47
+ *
48
+ * Config is env plus three runtime setters — setLogLevel(), setLogFormat(),
49
+ * setLogTime(). No handlers, no formatters, no init. NO_COLOR /
50
+ * FORCE_COLOR override color within human mode. Unknown values of any
51
+ * knob throw Panic. All internal memory is bounded.
52
+ *
53
+ * Siblings: go-utils and py-utils implement the same doctrine in their own
54
+ * idioms; the record line format is identical by design.
17
55
  */
56
+ import { panic } from "../offensive.js";
18
57
  import { runtime } from "../runtime.js";
19
- // ANSI escape codes for colors
20
- const ANSI = {
21
- reset: "\x1b[0m",
22
- bold: "\x1b[1m",
23
- dim: "\x1b[2m",
24
- // Foreground colors
25
- black: "\x1b[30m",
26
- red: "\x1b[31m",
27
- green: "\x1b[32m",
28
- yellow: "\x1b[33m",
29
- blue: "\x1b[34m",
30
- magenta: "\x1b[35m",
31
- cyan: "\x1b[36m",
32
- white: "\x1b[37m",
33
- gray: "\x1b[90m",
34
- // Custom purple (Next.js style)
35
- purple: "\x1b[38;2;173;127;168m",
36
- // Background colors
37
- bgRed: "\x1b[41m",
38
- bgGreen: "\x1b[42m",
39
- bgYellow: "\x1b[43m",
40
- bgBlue: "\x1b[44m",
41
- bgMagenta: "\x1b[45m",
42
- bgCyan: "\x1b[46m",
43
- bgWhite: "\x1b[47m",
44
- };
45
- // Detect if colors should be enabled
46
- const isColorEnabled = () => {
47
- if (runtime.isBrowser)
48
- return true; // Always enable colors in browser (CSS will handle it)
58
+ import { BOLD, DIM, fg, RESET } from "./ansi.js";
59
+ const sink = runtime.isNode || runtime.isBun || runtime.isDeno
60
+ ? "node"
61
+ : runtime.isBrowser
62
+ ? "browser"
63
+ : "worker";
64
+ let formatOverride;
65
+ /** Force human or record rendering at runtime. Wins over LOG_FORMAT and auto-detection. */
66
+ export function setLogFormat(format) {
67
+ if (format !== "human" && format !== "record")
68
+ panic("unknown log format:", format, "(want human|record)");
69
+ formatOverride = format;
70
+ }
71
+ function currentFormat() {
72
+ if (formatOverride)
73
+ return formatOverride;
74
+ const env = runtime.env("LOG_FORMAT");
75
+ if (env === "human" || env === "record")
76
+ return env;
77
+ if (env !== undefined)
78
+ panic("unknown LOG_FORMAT:", env, "(want human|record)");
79
+ if (sink === "node")
80
+ return runtime.stderr.isTTY ? "human" : "record";
81
+ return sink === "browser" ? "human" : "record";
82
+ }
83
+ // --- time ----------------------------------------------------------------
84
+ let timeOverride;
85
+ /** Force time prefixes on or off at runtime. Wins over LOG_TIME and the defaults. */
86
+ export function setLogTime(on) {
87
+ timeOverride = on;
88
+ }
89
+ // Default: on only for Node record mode. Human lines are for eyes; browser
90
+ // and Worker consoles stamp every line themselves.
91
+ function timeEnabled(format) {
92
+ if (timeOverride !== undefined)
93
+ return timeOverride;
94
+ const env = runtime.env("LOG_TIME");
95
+ if (env === "1")
96
+ return true;
97
+ if (env === "0")
98
+ return false;
99
+ if (env !== undefined)
100
+ panic("unknown LOG_TIME:", env, "(want 1|0)");
101
+ return format === "record" && sink === "node";
102
+ }
103
+ function colorEnabled() {
104
+ if (currentFormat() === "record")
105
+ return false;
49
106
  if (runtime.env("NO_COLOR"))
50
107
  return false;
51
108
  if (runtime.env("FORCE_COLOR"))
52
109
  return true;
53
- return (runtime.stdout.isTTY && !runtime.env("CI") && runtime.env("TERM") !== "dumb");
54
- };
55
- const colorEnabled = isColorEnabled();
56
- // Color formatter function
57
- const formatter = (open, close = ANSI.reset) => {
58
- if (!colorEnabled)
59
- return (str) => str;
60
- return (str) => `${open}${str}${close}`;
61
- };
62
- // Text styling functions
63
- export const bold = formatter(ANSI.bold);
64
- export const dim = formatter(ANSI.dim);
65
- export const red = formatter(ANSI.red);
66
- export const green = formatter(ANSI.green);
67
- export const yellow = formatter(ANSI.yellow);
68
- export const blue = formatter(ANSI.blue);
69
- export const magenta = formatter(ANSI.magenta);
70
- export const cyan = formatter(ANSI.cyan);
71
- export const white = formatter(ANSI.white);
72
- export const gray = formatter(ANSI.gray);
73
- export const purple = formatter(ANSI.purple);
74
- // Background colors
75
- export const bgRed = formatter(ANSI.bgRed);
76
- export const bgGreen = formatter(ANSI.bgGreen);
77
- export const bgYellow = formatter(ANSI.bgYellow);
78
- export const bgBlue = formatter(ANSI.bgBlue);
79
- export const bgMagenta = formatter(ANSI.bgMagenta);
80
- export const bgCyan = formatter(ANSI.bgCyan);
81
- export const bgWhite = formatter(ANSI.bgWhite);
82
- // Prefix symbols matching Next.js style
83
- const prefixes = {
84
- wait: white(bold("○")),
85
- error: red(bold("⨯")),
86
- warn: yellow(bold("⚠")),
87
- ready: "▶",
88
- info: white(bold(" ")),
89
- success: green(bold("✓")),
90
- event: green(bold("✓")),
91
- trace: magenta(bold("»")),
92
- };
93
- // Log level priority (lower number = higher priority)
94
- const LOG_LEVELS = {
95
- error: 0,
96
- warn: 1,
97
- info: 2,
98
- ready: 2,
99
- success: 2,
100
- event: 2,
101
- wait: 3,
102
- trace: 4,
110
+ if (sink === "browser")
111
+ return true;
112
+ if (sink === "worker")
113
+ return false;
114
+ return runtime.stderr.isTTY && runtime.env("TERM") !== "dumb";
115
+ }
116
+ // --- levels --------------------------------------------------------------
117
+ const LEVELS = {
118
+ silent: 0,
119
+ error: 1,
120
+ warn: 2,
121
+ info: 3,
122
+ debug: 4,
123
+ trace: 5,
103
124
  };
104
- function parseLogLevel(level) {
105
- switch (level?.toLowerCase()) {
106
- case "error":
107
- return LOG_LEVELS.error;
108
- case "warn":
109
- return LOG_LEVELS.warn;
110
- case "info":
111
- return LOG_LEVELS.info;
112
- case "debug":
113
- case "trace":
114
- return LOG_LEVELS.trace;
115
- default:
116
- return LOG_LEVELS.info;
117
- }
125
+ function parseLevel(raw) {
126
+ const n = LEVELS[raw.toLowerCase()];
127
+ if (n === undefined)
128
+ panic("unknown log level:", raw, "(want silent|error|warn|info|debug|trace)");
129
+ return n;
118
130
  }
119
- let currentLogLevel = parseLogLevel(runtime.env("LOG_LEVEL"));
120
- /** Set the global log level at runtime. Overrides LOG_LEVEL env var. */
131
+ let levelOverride;
132
+ /** Set the global log level at runtime. Wins over LOG_LEVEL; per-scope env vars still win over this. */
121
133
  export function setLogLevel(level) {
122
- currentLogLevel = parseLogLevel(level);
134
+ levelOverride = parseLevel(level);
123
135
  }
124
- // LRU Cache for warn-once functionality
125
- class LRUCache {
126
- cache = new Map();
127
- maxSize;
128
- constructor(maxSize) {
129
- this.maxSize = maxSize;
130
- }
131
- get(key) {
132
- const item = this.cache.get(key);
133
- if (item !== undefined) {
134
- // Move to end (most recently used)
135
- this.cache.delete(key);
136
- this.cache.set(key, item);
137
- }
138
- return item;
139
- }
140
- set(key, value) {
141
- if (this.cache.has(key)) {
142
- this.cache.delete(key);
143
- }
144
- this.cache.set(key, value);
145
- if (this.cache.size > this.maxSize) {
146
- // Remove least recently used (first item)
147
- const firstKey = this.cache.keys().next().value;
148
- if (firstKey !== undefined) {
149
- this.cache.delete(firstKey);
150
- }
151
- }
152
- }
153
- has(key) {
154
- return this.cache.has(key);
136
+ const envKey = (scopes) => `${scopes
137
+ .map((s) => s.toUpperCase().replace(/[^A-Z0-9]+/g, "_"))
138
+ .join("_")}_LOG_LEVEL`;
139
+ // Most specific scope wins: [api auth] reads API_AUTH_LOG_LEVEL, then
140
+ // API_LOG_LEVEL, then the runtime override, then LOG_LEVEL. Read live on
141
+ // every line so a level flip needs no restart hook.
142
+ function threshold(scopes) {
143
+ for (let i = scopes.length; i > 0; i--) {
144
+ const raw = runtime.env(envKey(scopes.slice(0, i)));
145
+ if (raw !== undefined)
146
+ return parseLevel(raw);
155
147
  }
148
+ if (levelOverride !== undefined)
149
+ return levelOverride;
150
+ const raw = runtime.env("LOG_LEVEL");
151
+ return raw === undefined ? LEVELS.info : parseLevel(raw);
156
152
  }
157
- const warnOnceCache = new LRUCache(10_000);
158
- // Core logging function
159
- function prefixedLog(level, ...messages) {
160
- // Check if this log level should be displayed
161
- const levelPriority = LOG_LEVELS[level] ?? LOG_LEVELS.info;
162
- if (levelPriority > currentLogLevel) {
163
- return; // Skip this log
153
+ const VERBS = {
154
+ error: { level: "error", symbol: "⨯", style: BOLD + fg(1), indent: "" },
155
+ fail: { level: "error", symbol: "⨯", style: BOLD + fg(1), indent: "" },
156
+ warn: { level: "warn", symbol: "⚠", style: BOLD + fg(3), indent: "" },
157
+ info: { level: "info", symbol: " ", style: "", indent: "" },
158
+ success: { level: "info", symbol: "✓", style: BOLD + fg(2), indent: "" },
159
+ wait: { level: "info", symbol: "○", style: BOLD + fg(7), indent: "" },
160
+ ready: { level: "info", symbol: "▶", style: BOLD + fg(2), indent: "" },
161
+ step: { level: "info", symbol: "•", style: DIM, indent: " " },
162
+ debug: { level: "debug", symbol: "◦", style: fg(8), indent: "" },
163
+ trace: { level: "trace", symbol: "»", style: BOLD + fg(5), indent: "" },
164
+ };
165
+ const LEVEL_WORD = {
166
+ error: "ERROR",
167
+ warn: "WARN ",
168
+ info: "INFO ",
169
+ debug: "DEBUG",
170
+ trace: "TRACE",
171
+ };
172
+ // --- rendering -----------------------------------------------------------
173
+ const rfc3339 = () => `${new Date().toISOString().slice(0, 19)}Z`;
174
+ const clockTime = () => new Date().toTimeString().slice(0, 8);
175
+ function stringify(m) {
176
+ if (typeof m === "string")
177
+ return m;
178
+ if (m instanceof Error)
179
+ return m.stack ?? String(m);
180
+ try {
181
+ return JSON.stringify(m) ?? String(m);
164
182
  }
165
- // Remove empty first message
166
- if ((messages[0] === "" || messages[0] === undefined) &&
167
- messages.length === 1) {
168
- messages.shift();
183
+ catch {
184
+ return String(m);
169
185
  }
170
- // Determine console method based on level
171
- const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
172
- const prefix = prefixes[level];
173
- // Handle empty messages
174
- if (messages.length === 0) {
175
- console[consoleMethod]("");
186
+ }
187
+ const CONSOLE_METHOD = {
188
+ error: "error",
189
+ warn: "warn",
190
+ info: "info",
191
+ debug: "debug",
192
+ trace: "debug",
193
+ };
194
+ function emit(scopes, verb, messages) {
195
+ const v = VERBS[verb];
196
+ if (LEVELS[v.level] > threshold(scopes))
197
+ return;
198
+ // Node funnels every level through console.error: stderr is the log sink
199
+ // and cli/live patches console methods to reroute lines above an active
200
+ // region. Browser/worker use level-mapped methods so platform consoles
201
+ // classify lines correctly.
202
+ const method = sink === "node" ? "error" : CONSOLE_METHOD[v.level];
203
+ const format = currentFormat();
204
+ if (format === "record") {
205
+ const stamp = timeEnabled(format) ? `${rfc3339()} ` : "";
206
+ const scope = scopes.length > 0 ? `[${scopes.join(" ")}] ` : "";
207
+ console[method](`${stamp}${LEVEL_WORD[v.level]} ${scope}${messages.map(stringify).join(" ")}`);
176
208
  return;
177
209
  }
178
- // Format and log the message
210
+ const color = colorEnabled();
211
+ const dimmed = (s) => (color ? `${DIM}${s}${RESET}` : s);
212
+ const stamp = timeEnabled(format) ? `${dimmed(clockTime())} ` : "";
213
+ const symbol = color ? `${v.style}${v.symbol}${RESET}` : v.symbol;
214
+ const scope = scopes.length > 0 ? `${dimmed(`[${scopes.join(" ")}]`)} ` : "";
215
+ const prefix = `${stamp}${v.indent} ${symbol} ${scope}`;
216
+ // A single string collapses into one line; anything richer is handed to
217
+ // console so devtools/util.inspect keep live object rendering.
179
218
  if (messages.length === 1 && typeof messages[0] === "string") {
180
- console[consoleMethod](` ${prefix} ${messages[0]}`);
219
+ console[method](`${prefix}${messages[0]}`);
181
220
  }
182
221
  else {
183
- console[consoleMethod](` ${prefix}`, ...messages);
184
- }
185
- }
186
- // Bootstrap function for startup messages (no prefix, just indentation)
187
- export function bootstrap(...messages) {
188
- console.log(` ${messages.join(" ")}`);
189
- }
190
- // Main logging functions
191
- export function wait(...messages) {
192
- prefixedLog("wait", ...messages);
193
- }
194
- export function error(...messages) {
195
- prefixedLog("error", ...messages);
196
- }
197
- export function warn(...messages) {
198
- prefixedLog("warn", ...messages);
199
- }
200
- export function ready(...messages) {
201
- prefixedLog("ready", ...messages);
202
- }
203
- export function info(...messages) {
204
- prefixedLog("info", ...messages);
205
- }
206
- export function success(...messages) {
207
- prefixedLog("success", ...messages);
208
- }
209
- export function event(...messages) {
210
- prefixedLog("event", ...messages);
211
- }
212
- export function trace(...messages) {
213
- prefixedLog("trace", ...messages);
214
- }
215
- // Special warn-once function
216
- export function warnOnce(...messages) {
217
- const key = messages.join(" ");
218
- if (!warnOnceCache.has(key)) {
219
- warnOnceCache.set(key, true);
220
- warn(...messages);
222
+ console[method](prefix.trimEnd(), ...messages);
221
223
  }
222
224
  }
223
- // Timer functionality for measuring durations
225
+ // --- bounded state (daemons run for months) ------------------------------
226
+ const BOUND = 1024;
227
+ const warned = new Set();
224
228
  const timers = new Map();
225
- export function time(label) {
226
- timers.set(label, Date.now());
227
- }
228
- export function timeEnd(label) {
229
- const start = timers.get(label);
230
- if (start === undefined) {
231
- warn(`Timer '${label}' does not exist`);
232
- return;
233
- }
234
- const duration = Date.now() - start;
235
- timers.delete(label);
236
- const formatted = duration > 10000
237
- ? `${Math.round(duration / 100) / 10}s`
238
- : `${Math.round(duration)}ms`;
239
- trace(`${label}: ${formatted}`);
240
- }
241
- // Utility function to create a prefixed logger instance
242
- export function createLogger(prefix) {
229
+ function makeLogger(scopes) {
230
+ const verb = (v) => (...messages) => emit(scopes, v, messages);
243
231
  return {
244
- wait: (...messages) => wait(`[${prefix}]`, ...messages),
245
- error: (...messages) => error(`[${prefix}]`, ...messages),
246
- warn: (...messages) => warn(`[${prefix}]`, ...messages),
247
- ready: (...messages) => ready(`[${prefix}]`, ...messages),
248
- info: (...messages) => info(`[${prefix}]`, ...messages),
249
- success: (...messages) => success(`[${prefix}]`, ...messages),
250
- event: (...messages) => event(`[${prefix}]`, ...messages),
251
- trace: (...messages) => trace(`[${prefix}]`, ...messages),
252
- warnOnce: (...messages) => warnOnce(`[${prefix}]`, ...messages),
253
- time: (label) => time(`${prefix}:${label}`),
254
- timeEnd: (label) => timeEnd(`${prefix}:${label}`),
232
+ error: verb("error"),
233
+ fail: verb("fail"),
234
+ warn: verb("warn"),
235
+ info: verb("info"),
236
+ success: verb("success"),
237
+ wait: verb("wait"),
238
+ ready: verb("ready"),
239
+ step: verb("step"),
240
+ debug: verb("debug"),
241
+ trace: verb("trace"),
242
+ warnOnce(...messages) {
243
+ const key = scopes.join(" ") + messages.map(stringify).join(" ");
244
+ if (warned.has(key))
245
+ return;
246
+ if (warned.size >= BOUND)
247
+ warned.clear();
248
+ warned.add(key);
249
+ emit(scopes, "warn", messages);
250
+ },
251
+ time(label) {
252
+ if (timers.size >= BOUND)
253
+ timers.clear();
254
+ timers.set(`${scopes.join(" ")}:${label}`, Date.now());
255
+ },
256
+ timeEnd(label) {
257
+ const key = `${scopes.join(" ")}:${label}`;
258
+ const start = timers.get(key);
259
+ if (start === undefined) {
260
+ emit(scopes, "warn", [`Timer '${label}' does not exist`]);
261
+ return;
262
+ }
263
+ timers.delete(key);
264
+ const ms = Date.now() - start;
265
+ const pretty = ms > 10_000 ? `${Math.round(ms / 100) / 10}s` : `${Math.round(ms)}ms`;
266
+ emit(scopes, "trace", [`${label}: ${pretty}`]);
267
+ },
268
+ scope(name) {
269
+ return makeLogger([...scopes, name]);
270
+ },
255
271
  };
256
272
  }
257
- // Export a default log instance
258
- const log = {
259
- wait,
260
- error,
261
- warn,
262
- ready,
263
- info,
264
- success,
265
- event,
266
- trace,
267
- warnOnce,
268
- time,
269
- timeEnd,
270
- bootstrap,
271
- createLogger,
273
+ const root = makeLogger([]);
274
+ export const error = root.error;
275
+ export const fail = root.fail;
276
+ export const warn = root.warn;
277
+ export const warnOnce = root.warnOnce;
278
+ export const info = root.info;
279
+ export const success = root.success;
280
+ export const wait = root.wait;
281
+ export const ready = root.ready;
282
+ export const step = root.step;
283
+ export const debug = root.debug;
284
+ export const trace = root.trace;
285
+ export const time = root.time;
286
+ export const timeEnd = root.timeEnd;
287
+ export const scope = root.scope;
288
+ /** The root logger plus its runtime knobs, for namespace-style consumers. */
289
+ export const log = {
290
+ ...root,
272
291
  setLogLevel,
273
- // Color utilities
274
- colors: {
275
- bold,
276
- dim,
277
- red,
278
- green,
279
- yellow,
280
- blue,
281
- magenta,
282
- cyan,
283
- white,
284
- gray,
285
- purple,
286
- bgRed,
287
- bgGreen,
288
- bgYellow,
289
- bgBlue,
290
- bgMagenta,
291
- bgCyan,
292
- bgWhite,
293
- },
292
+ setLogFormat,
293
+ setLogTime,
294
294
  };
295
- export { log };
296
- // Example usage helper
297
- export function logAppStartup(options) {
298
- const { name, version, port, host = "localhost", environment } = options;
299
- bootstrap(bold(purple(`▶ ${name} ${version}`)));
300
- bootstrap(`- Local: http://${host}:${port}`);
301
- if (environment) {
302
- bootstrap(`- Environment: ${environment}`);
303
- }
304
- info("");
305
- }
306
295
  //# sourceMappingURL=log.js.map