millas 0.2.6 → 0.2.8

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,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * NullChannel
5
+ *
6
+ * Silently discards all log entries.
7
+ * Useful in tests where you want to suppress all output.
8
+ *
9
+ * Log.configure({ channels: [new NullChannel()] });
10
+ */
11
+ class NullChannel {
12
+ write() { /* intentionally empty */ }
13
+ }
14
+
15
+ /**
16
+ * StackChannel
17
+ *
18
+ * Fans a single log entry out to multiple channels simultaneously.
19
+ * This is the standard "stack" pattern — one channel for console,
20
+ * another for file, optionally one for an external service.
21
+ *
22
+ * new StackChannel([
23
+ * new ConsoleChannel({ formatter: new PrettyFormatter() }),
24
+ * new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.INFO }),
25
+ * ])
26
+ */
27
+ class StackChannel {
28
+ /**
29
+ * @param {Array} channels — array of channel instances
30
+ */
31
+ constructor(channels = []) {
32
+ this._channels = channels;
33
+ }
34
+
35
+ /** Add a channel at runtime. */
36
+ add(channel) {
37
+ this._channels.push(channel);
38
+ return this;
39
+ }
40
+
41
+ write(entry) {
42
+ for (const ch of this._channels) {
43
+ try { ch.write(entry); } catch {}
44
+ }
45
+ }
46
+ }
47
+
48
+ module.exports = { NullChannel, StackChannel };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const { LEVEL_NAMES } = require('../levels');
4
+
5
+ /**
6
+ * JsonFormatter
7
+ *
8
+ * Emits one JSON object per log entry — ideal for production environments
9
+ * where logs are shipped to Datadog, Elasticsearch, CloudWatch, etc.
10
+ *
11
+ * Output (one line per entry):
12
+ * {"ts":"2026-03-15T12:00:00.000Z","level":"INFO","tag":"Auth","msg":"Login","ctx":{...}}
13
+ */
14
+ class JsonFormatter {
15
+ /**
16
+ * @param {object} options
17
+ * @param {boolean} [options.pretty=false] — pretty-print JSON (for debugging)
18
+ * @param {object} [options.extra] — static fields merged into every entry (e.g. service name)
19
+ */
20
+ constructor(options = {}) {
21
+ this.pretty = options.pretty || false;
22
+ this.extra = options.extra || {};
23
+ }
24
+
25
+ format(entry) {
26
+ const { level, tag, message, context, error, timestamp } = entry;
27
+
28
+ const record = {
29
+ ts: timestamp || new Date().toISOString(),
30
+ level: LEVEL_NAMES[level] || String(level),
31
+ ...this.extra,
32
+ };
33
+
34
+ if (tag) record.tag = tag;
35
+ record.msg = message;
36
+ if (context !== undefined && context !== null) record.ctx = context;
37
+
38
+ if (error instanceof Error) {
39
+ record.error = {
40
+ message: error.message,
41
+ name: error.name,
42
+ stack: error.stack,
43
+ };
44
+ }
45
+
46
+ return this.pretty
47
+ ? JSON.stringify(record, null, 2)
48
+ : JSON.stringify(record);
49
+ }
50
+ }
51
+
52
+ module.exports = JsonFormatter;
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ const { LEVEL_NAMES, LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD, DIM } = require('../levels');
4
+
5
+ /**
6
+ * PrettyFormatter
7
+ *
8
+ * Colourful, human-readable output. Designed for development.
9
+ * Inspired by Timber (Android) and Laravel's log formatting.
10
+ *
11
+ * Output:
12
+ * [2026-03-15 12:00:00] I UserController User #5 logged in
13
+ * [2026-03-15 12:00:01] E Database Connection refused { host: 'localhost' }
14
+ * [2026-03-15 12:00:02] W Auth Token expiring soon
15
+ *
16
+ * WTF level also prints the full stack trace.
17
+ */
18
+ class PrettyFormatter {
19
+ /**
20
+ * @param {object} options
21
+ * @param {boolean} [options.timestamp=true] — show timestamp
22
+ * @param {boolean} [options.tag=true] — show tag/component name
23
+ * @param {boolean} [options.colour=true] — ANSI colour (disable for pipes/files)
24
+ * @param {string} [options.timestampFormat] — 'iso' | 'short' (default: 'short')
25
+ */
26
+ constructor(options = {}) {
27
+ this.showTimestamp = options.timestamp !== false;
28
+ this.showTag = options.tag !== false;
29
+ this.colour = options.colour !== false;
30
+ this.tsFormat = options.timestampFormat || 'short';
31
+ }
32
+
33
+ format(entry) {
34
+ const { level, tag, message, context, error } = entry;
35
+
36
+ const c = this.colour ? LEVEL_COLOURS[level] : '';
37
+ const r = this.colour ? RESET : '';
38
+ const b = this.colour ? BOLD : '';
39
+ const d = this.colour ? '\x1b[2m' : '';
40
+ const lvl = LEVEL_TAGS[level] || '?';
41
+
42
+ const parts = [];
43
+
44
+ // Timestamp
45
+ if (this.showTimestamp) {
46
+ const ts = this._timestamp();
47
+ parts.push(`${d}[${ts}]${r}`);
48
+ }
49
+
50
+ // Level tag (single letter, coloured)
51
+ parts.push(`${c}${b}${lvl}${r}`);
52
+
53
+ // Component/tag
54
+ if (this.showTag && tag) {
55
+ const tagStr = tag.padEnd(18);
56
+ parts.push(`${b}${tagStr}${r}`);
57
+ }
58
+
59
+ // Message
60
+ parts.push(`${c}${message}${r}`);
61
+
62
+ // Context object
63
+ if (context !== undefined && context !== null) {
64
+ const ctx = typeof context === 'object'
65
+ ? JSON.stringify(context, null, 0)
66
+ : String(context);
67
+ parts.push(`${d}${ctx}${r}`);
68
+ }
69
+
70
+ let output = parts.join(' ');
71
+
72
+ // Error stack
73
+ if (error instanceof Error) {
74
+ output += `\n${d}${error.stack || error.message}${r}`;
75
+ }
76
+
77
+ // WTF: print big warning banner
78
+ if (level === 5) {
79
+ const banner = this.colour
80
+ ? `\x1b[35m\x1b[1m${'━'.repeat(60)}\x1b[0m`
81
+ : '━'.repeat(60);
82
+ output = `${banner}\n${output}\n${banner}`;
83
+ }
84
+
85
+ return output;
86
+ }
87
+
88
+ _timestamp() {
89
+ const now = new Date();
90
+ if (this.tsFormat === 'iso') return now.toISOString();
91
+ return now.toISOString().replace('T', ' ').slice(0, 19);
92
+ }
93
+ }
94
+
95
+ module.exports = PrettyFormatter;
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const { LEVEL_NAMES } = require('../levels');
4
+
5
+ /**
6
+ * SimpleFormatter
7
+ *
8
+ * Plain, no-colour text. Suitable for file output or any sink
9
+ * where ANSI codes would be noise.
10
+ *
11
+ * Output:
12
+ * [2026-03-15 12:00:00] [INFO] Auth: User logged in
13
+ * [2026-03-15 12:00:01] [ERROR] DB: Query failed {"table":"users"}
14
+ */
15
+ class SimpleFormatter {
16
+ format(entry) {
17
+ const { level, tag, message, context, error, timestamp } = entry;
18
+
19
+ const ts = (timestamp || new Date().toISOString()).replace('T', ' ').slice(0, 23);
20
+ const lvlName = (LEVEL_NAMES[level] || String(level)).padEnd(7);
21
+ const tagPart = tag ? `${tag}: ` : '';
22
+
23
+ let line = `[${ts}] [${lvlName}] ${tagPart}${message}`;
24
+
25
+ if (context !== undefined && context !== null) {
26
+ line += ' ' + (typeof context === 'object' ? JSON.stringify(context) : String(context));
27
+ }
28
+
29
+ if (error instanceof Error) {
30
+ line += '\n ' + (error.stack || error.message).replace(/\n/g, '\n ');
31
+ }
32
+
33
+ return line;
34
+ }
35
+ }
36
+
37
+ module.exports = SimpleFormatter;
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const Logger = require('./Logger');
4
+ const { LEVELS, LEVEL_NAMES } = require('./levels');
5
+ const PrettyFormatter = require('./formatters/PrettyFormatter');
6
+ const JsonFormatter = require('./formatters/JsonFormatter');
7
+ const SimpleFormatter = require('./formatters/SimpleFormatter');
8
+ const ConsoleChannel = require('./channels/ConsoleChannel');
9
+ const FileChannel = require('./channels/FileChannel');
10
+ const { NullChannel, StackChannel } = require('./channels/index');
11
+
12
+ /**
13
+ * Log
14
+ *
15
+ * The global logger singleton — the one you import everywhere.
16
+ *
17
+ * const { Log } = require('millas');
18
+ *
19
+ * Log.i('App booted');
20
+ * Log.tag('UserService').d('Fetching user', { id: 5 });
21
+ * Log.e('Payment', 'Stripe failed', error);
22
+ * Log.wtf('Impossible state reached');
23
+ *
24
+ * Configured automatically by LogServiceProvider when you add it
25
+ * to your providers list. For manual setup:
26
+ *
27
+ * Log.configure({
28
+ * minLevel: LEVELS.INFO,
29
+ * channel: new StackChannel([
30
+ * new ConsoleChannel({ formatter: new PrettyFormatter() }),
31
+ * new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.WARN }),
32
+ * ]),
33
+ * });
34
+ */
35
+ const Log = new Logger();
36
+
37
+ // Apply sensible defaults so Log works out-of-the-box before
38
+ // LogServiceProvider runs (during framework boot, tests, etc.)
39
+ Log.configure({
40
+ minLevel: process.env.NODE_ENV === 'production' ? LEVELS.INFO : LEVELS.DEBUG,
41
+ channel: new ConsoleChannel({
42
+ formatter: new PrettyFormatter({
43
+ colour: process.stdout.isTTY !== false,
44
+ }),
45
+ }),
46
+ });
47
+
48
+ module.exports = {
49
+ // The singleton you use everywhere
50
+ Log,
51
+
52
+ // The class (for constructing named loggers)
53
+ Logger,
54
+
55
+ // Level constants
56
+ LEVELS,
57
+ LEVEL_NAMES,
58
+
59
+ // Formatters
60
+ PrettyFormatter,
61
+ JsonFormatter,
62
+ SimpleFormatter,
63
+
64
+ // Channels
65
+ ConsoleChannel,
66
+ FileChannel,
67
+ NullChannel,
68
+ StackChannel,
69
+ };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Log levels — ordered by severity (lowest to highest).
5
+ *
6
+ * Inspired by Android's Timber / Log class:
7
+ * VERBOSE — extremely detailed; usually filtered out in production
8
+ * DEBUG — development diagnostics
9
+ * INFO — normal operational messages (default minimum in production)
10
+ * WARN — something unexpected but recoverable
11
+ * ERROR — something failed; needs attention
12
+ * WTF — "What a Terrible Failure" — should never happen; always logged
13
+ */
14
+ const LEVELS = {
15
+ VERBOSE: 0,
16
+ DEBUG: 1,
17
+ INFO: 2,
18
+ WARN: 3,
19
+ ERROR: 4,
20
+ WTF: 5,
21
+ };
22
+
23
+ /** Reverse map: number → name */
24
+ const LEVEL_NAMES = Object.fromEntries(
25
+ Object.entries(LEVELS).map(([k, v]) => [v, k])
26
+ );
27
+
28
+ /** Single-letter tags (like Android logcat) */
29
+ const LEVEL_TAGS = {
30
+ 0: 'V',
31
+ 1: 'D',
32
+ 2: 'I',
33
+ 3: 'W',
34
+ 4: 'E',
35
+ 5: 'F', // Fatal / WTF
36
+ };
37
+
38
+ /** ANSI colour codes for each level */
39
+ const LEVEL_COLOURS = {
40
+ 0: '\x1b[90m', // VERBOSE — dark grey
41
+ 1: '\x1b[36m', // DEBUG — cyan
42
+ 2: '\x1b[32m', // INFO — green
43
+ 3: '\x1b[33m', // WARN — yellow
44
+ 4: '\x1b[31m', // ERROR — red
45
+ 5: '\x1b[35m', // WTF — magenta
46
+ };
47
+
48
+ const RESET = '\x1b[0m';
49
+ const BOLD = '\x1b[1m';
50
+ const DIM = '\x1b[2m';
51
+
52
+ module.exports = { LEVELS, LEVEL_NAMES, LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD, DIM };
@@ -5,53 +5,79 @@ const Middleware = require('./Middleware');
5
5
  /**
6
6
  * LogMiddleware
7
7
  *
8
- * Logs every incoming request and its response time.
8
+ * Django-style HTTP request logger.
9
+ * Uses the Millas Log singleton — output goes through your configured channels.
9
10
  *
10
- * Output format:
11
- * [2026-03-14 12:00:00] GET /api/users 200 12ms
11
+ * Log levels per status:
12
+ * 2xx, 3xx → INFO (green)
13
+ * 4xx → WARN (yellow)
14
+ * 5xx → ERROR (red)
15
+ * slow req → WARN (with slow=true in context)
12
16
  *
13
- * Register:
14
- * middlewareRegistry.register('log', LogMiddleware);
17
+ * Output:
18
+ * I HTTP GET /api/users 200 4ms
19
+ * W HTTP POST /api/login 401 12ms
20
+ * E HTTP GET /api/crash 500 3ms
21
+ *
22
+ * Usage (register as middleware):
23
+ * app.use(new LogMiddleware().handle.bind(new LogMiddleware()));
24
+ *
25
+ * Or use Log.requestMiddleware() directly for more options.
15
26
  *
16
27
  * Options:
17
- * silent — suppress output (default: false)
18
- * format'dev' (coloured) | 'combined' (plain)
28
+ * silent — suppress all output (default: false)
29
+ * includeQueryinclude query string in URL (default: false)
30
+ * includeIp — include client IP (default: true)
31
+ * slowThreshold — warn if response > Nms (default: 1000)
32
+ * skip — function(req, res) => bool — skip matching routes
19
33
  */
20
34
  class LogMiddleware extends Middleware {
21
35
  constructor(options = {}) {
22
36
  super();
23
- this.silent = options.silent || false;
24
- this.format = options.format || 'dev';
37
+ this.silent = options.silent ?? false;
38
+ this.includeQuery = options.includeQuery ?? false;
39
+ this.includeIp = options.includeIp ?? true;
40
+ this.slowThreshold = options.slowThreshold ?? 1000;
41
+ this.skip = options.skip ?? null;
25
42
  }
26
43
 
27
44
  async handle(req, res, next) {
28
45
  if (this.silent) return next();
46
+ if (typeof this.skip === 'function' && this.skip(req, res)) return next();
47
+
48
+ // Lazy-require Log so this file loads even before LogServiceProvider runs
49
+ let Log;
50
+ try {
51
+ Log = require('../logger/index').Log;
52
+ } catch {
53
+ return next();
54
+ }
29
55
 
30
56
  const start = Date.now();
31
- const { method, path: routePath, url } = req;
57
+ const { LEVELS } = require('../logger/levels');
32
58
 
33
59
  res.on('finish', () => {
34
60
  const ms = Date.now() - start;
35
61
  const status = res.statusCode;
36
- const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
37
-
38
- if (this.format === 'dev') {
39
- const statusColor = status < 300 ? '\x1b[32m'
40
- : status < 400 ? '\x1b[36m'
41
- : status < 500 ? '\x1b[33m'
42
- : '\x1b[31m';
43
- const reset = '\x1b[0m';
44
- const methodColor = '\x1b[1m';
45
- console.log(
46
- ` ${'\x1b[90m'}[${ts}]${reset} ` +
47
- `${methodColor}${method.padEnd(7)}${reset} ` +
48
- `${routePath || url} ` +
49
- `${statusColor}${status}${reset} ` +
50
- `${'\x1b[90m'}${ms}ms${reset}`
51
- );
52
- } else {
53
- console.log(`[${ts}] ${method} ${routePath || url} ${status} ${ms}ms`);
62
+ const method = req.method;
63
+ let url = req.path || req.url || '/';
64
+
65
+ if (this.includeQuery && req.url && req.url.includes('?')) {
66
+ url = req.url;
54
67
  }
68
+
69
+ // Level based on status + response time
70
+ let level;
71
+ if (status >= 500) level = LEVELS.ERROR;
72
+ else if (status >= 400) level = LEVELS.WARN;
73
+ else if (ms > this.slowThreshold) level = LEVELS.WARN;
74
+ else level = LEVELS.INFO;
75
+
76
+ const ctx = { status, ms };
77
+ if (this.includeIp) ctx.ip = req.ip || req.connection?.remoteAddress;
78
+ if (ms > this.slowThreshold) ctx.slow = true;
79
+
80
+ Log._log(level, 'HTTP', `${method} ${url} ${status} ${ms}ms`, ctx);
55
81
  });
56
82
 
57
83
  next();
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const ServiceProvider = require('./ServiceProvider');
4
+ const {
5
+ Log,
6
+ Logger,
7
+ LEVELS,
8
+ PrettyFormatter,
9
+ JsonFormatter,
10
+ SimpleFormatter,
11
+ ConsoleChannel,
12
+ FileChannel,
13
+ NullChannel,
14
+ StackChannel,
15
+ } = require('../logger/index');
16
+
17
+ /**
18
+ * LogServiceProvider
19
+ *
20
+ * Reads config/logging.js and configures the Log singleton.
21
+ * Also registers Log and Logger in the DI container.
22
+ *
23
+ * Add to bootstrap/app.js:
24
+ * app.providers([LogServiceProvider, ...]);
25
+ *
26
+ * The provider is intentionally ordered first so every other provider
27
+ * can use Log during their boot() method.
28
+ *
29
+ * config/logging.js is optional — sensible defaults apply if absent.
30
+ */
31
+ class LogServiceProvider extends ServiceProvider {
32
+ register(container) {
33
+ container.instance('Log', Log);
34
+ container.instance('Logger', Logger);
35
+ }
36
+
37
+ async boot(container, app) {
38
+ let config = {};
39
+ try {
40
+ config = require(process.cwd() + '/config/logging');
41
+ } catch {
42
+ // No config — use defaults already set in logger/index.js
43
+ return;
44
+ }
45
+
46
+ const channels = this._buildChannels(config);
47
+
48
+ Log.configure({
49
+ minLevel: this._resolveLevel(config.level ?? config.minLevel),
50
+ defaultTag: config.defaultTag || 'App',
51
+ channel: channels.length === 1
52
+ ? channels[0]
53
+ : new StackChannel(channels),
54
+ });
55
+
56
+ Log.tag('Millas').i(`Logger configured — level: ${this._levelName(Log._minLevel)}, channels: ${channels.length}`);
57
+ }
58
+
59
+ // ─── Private ──────────────────────────────────────────────────────────────
60
+
61
+ _buildChannels(config) {
62
+ const channelDefs = config.channels || ['console'];
63
+ const built = [];
64
+
65
+ for (const def of channelDefs) {
66
+ // String shorthand: 'console' | 'file' | 'null'
67
+ if (def === 'console' || def?.driver === 'console') {
68
+ built.push(this._buildConsole(def));
69
+ } else if (def === 'file' || def?.driver === 'file') {
70
+ built.push(this._buildFile(def));
71
+ } else if (def === 'null' || def?.driver === 'null') {
72
+ built.push(new NullChannel());
73
+ } else if (def && typeof def.write === 'function') {
74
+ // Already an instantiated channel — use directly
75
+ built.push(def);
76
+ }
77
+ }
78
+
79
+ // Always have at least a console channel
80
+ if (!built.length) built.push(this._buildConsole({}));
81
+
82
+ return built;
83
+ }
84
+
85
+ _buildConsole(opts = {}) {
86
+ const fmt = this._buildFormatter(opts.formatter || opts.format || 'pretty', opts);
87
+ return new ConsoleChannel({
88
+ formatter: fmt,
89
+ minLevel: this._resolveLevel(opts.level ?? opts.minLevel),
90
+ });
91
+ }
92
+
93
+ _buildFile(opts = {}) {
94
+ const fmt = this._buildFormatter(opts.formatter || opts.format || 'simple', opts);
95
+ return new FileChannel({
96
+ path: opts.path || 'storage/logs',
97
+ prefix: opts.prefix || 'millas',
98
+ formatter: fmt,
99
+ minLevel: this._resolveLevel(opts.level ?? opts.minLevel),
100
+ maxFiles: opts.maxFiles ?? 30,
101
+ });
102
+ }
103
+
104
+ _buildFormatter(name, opts = {}) {
105
+ if (name && typeof name === 'object' && typeof name.format === 'function') {
106
+ return name; // already an instance
107
+ }
108
+ switch (String(name).toLowerCase()) {
109
+ case 'json':
110
+ return new JsonFormatter({ extra: opts.extra });
111
+ case 'simple':
112
+ return new SimpleFormatter();
113
+ case 'pretty':
114
+ default:
115
+ return new PrettyFormatter({
116
+ colour: opts.colour !== false && process.stdout.isTTY !== false,
117
+ timestamp: opts.timestamp !== false,
118
+ tag: opts.tag !== false,
119
+ timestampFormat: opts.timestampFormat || 'short',
120
+ });
121
+ }
122
+ }
123
+
124
+ _resolveLevel(level) {
125
+ if (level === undefined || level === null) {
126
+ return process.env.NODE_ENV === 'production' ? LEVELS.INFO : LEVELS.DEBUG;
127
+ }
128
+ if (typeof level === 'number') return level;
129
+ return LEVELS[String(level).toUpperCase()] ?? LEVELS.DEBUG;
130
+ }
131
+
132
+ _levelName(n) {
133
+ const names = ['VERBOSE','DEBUG','INFO','WARN','ERROR','WTF'];
134
+ return names[n] || String(n);
135
+ }
136
+ }
137
+
138
+ module.exports = LogServiceProvider;