@techstream/quark-core 1.1.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.
package/src/logger.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @techstream/quark-core - Logger Module
3
+ * Lightweight structured logger with zero external dependencies
4
+ */
5
+
6
+ /** @enum {number} */
7
+ const LOG_LEVELS = {
8
+ debug: 10,
9
+ info: 20,
10
+ warn: 30,
11
+ error: 40,
12
+ fatal: 50,
13
+ };
14
+
15
+ const LEVEL_NAMES = /** @type {const} */ ({
16
+ 10: "debug",
17
+ 20: "info",
18
+ 30: "warn",
19
+ 40: "error",
20
+ 50: "fatal",
21
+ });
22
+
23
+ /** @type {Record<string, string>} */
24
+ const LEVEL_COLORS = {
25
+ debug: "\x1b[36m", // cyan
26
+ info: "\x1b[32m", // green
27
+ warn: "\x1b[33m", // yellow
28
+ error: "\x1b[31m", // red
29
+ fatal: "\x1b[35m", // magenta
30
+ };
31
+
32
+ const RESET = "\x1b[0m";
33
+ const DIM = "\x1b[2m";
34
+
35
+ const isProduction = () => process.env.NODE_ENV === "production";
36
+
37
+ /**
38
+ * Resolves the minimum log level from options and environment
39
+ * @param {string} [optionLevel] - Level from options
40
+ * @returns {number}
41
+ */
42
+ const resolveLevel = (optionLevel) => {
43
+ const level =
44
+ optionLevel || process.env.LOG_LEVEL || (isProduction() ? "info" : "debug");
45
+ return LOG_LEVELS[level] ?? LOG_LEVELS.info;
46
+ };
47
+
48
+ /**
49
+ * Formats a log entry for dev (colorized, human-readable)
50
+ * @param {object} entry - The log entry
51
+ * @returns {string}
52
+ */
53
+ const formatDev = (entry) => {
54
+ const { timestamp, level, name, msg, ...rest } = entry;
55
+ const color = LEVEL_COLORS[level] || "";
56
+ const time = timestamp.slice(11, 23); // HH:mm:ss.SSS
57
+ const contextStr =
58
+ Object.keys(rest).length > 0
59
+ ? ` ${DIM}${JSON.stringify(rest)}${RESET}`
60
+ : "";
61
+ return `${DIM}${time}${RESET} ${color}${level.toUpperCase().padEnd(5)}${RESET} ${DIM}[${name}]${RESET} ${msg}${contextStr}`;
62
+ };
63
+
64
+ /**
65
+ * Formats a log entry for production (single-line JSON)
66
+ * @param {object} entry - The log entry
67
+ * @returns {string}
68
+ */
69
+ const formatProd = (entry) => {
70
+ return JSON.stringify(entry);
71
+ };
72
+
73
+ /**
74
+ * Returns the console method for a given level
75
+ * @param {string} level - The log level name
76
+ * @returns {Function}
77
+ */
78
+ const getTransport = (level) => {
79
+ if (level === "error" || level === "fatal") return console.error;
80
+ if (level === "warn") return console.warn;
81
+ return console.log;
82
+ };
83
+
84
+ /**
85
+ * @typedef {Object} LoggerOptions
86
+ * @property {string} [name] - Logger name (e.g. "web", "worker", "db")
87
+ * @property {string} [level] - Minimum log level (default: "info" in production, "debug" in dev)
88
+ * @property {Object} [context] - Default context merged into every log entry
89
+ */
90
+
91
+ /**
92
+ * @typedef {Object} Logger
93
+ * @property {(msg: string, data?: Object) => void} debug - Log at debug level
94
+ * @property {(msg: string, data?: Object) => void} info - Log at info level
95
+ * @property {(msg: string, data?: Object) => void} warn - Log at warn level
96
+ * @property {(msg: string, data?: Object) => void} error - Log at error level
97
+ * @property {(msg: string, data?: Object) => void} fatal - Log at fatal level
98
+ * @property {(context: Object) => Logger} child - Create a child logger with merged context
99
+ */
100
+
101
+ /**
102
+ * Creates a structured logger instance
103
+ * @param {LoggerOptions} [options] - Logger configuration
104
+ * @returns {Logger}
105
+ */
106
+ export const createLogger = (options = {}) => {
107
+ const {
108
+ name = "app",
109
+ level: optionLevel,
110
+ context: defaultContext = {},
111
+ } = options;
112
+ const minLevel = resolveLevel(optionLevel);
113
+ const format = isProduction() ? formatProd : formatDev;
114
+
115
+ /**
116
+ * Writes a log entry if the level is at or above the minimum
117
+ * @param {string} levelName - The level name
118
+ * @param {string} msg - The log message
119
+ * @param {Object} [data] - Additional data
120
+ */
121
+ const write = (levelName, msg, data = {}) => {
122
+ const numeric = LOG_LEVELS[levelName];
123
+ if (numeric < minLevel) return;
124
+
125
+ const entry = {
126
+ timestamp: new Date().toISOString(),
127
+ level: levelName,
128
+ name,
129
+ msg,
130
+ ...defaultContext,
131
+ ...data,
132
+ };
133
+
134
+ const transport = getTransport(levelName);
135
+ transport(format(entry));
136
+ };
137
+
138
+ return {
139
+ debug: (msg, data) => write("debug", msg, data),
140
+ info: (msg, data) => write("info", msg, data),
141
+ warn: (msg, data) => write("warn", msg, data),
142
+ error: (msg, data) => write("error", msg, data),
143
+ fatal: (msg, data) => write("fatal", msg, data),
144
+
145
+ /**
146
+ * Creates a child logger with merged context
147
+ * @param {Object} childContext - Additional context for the child logger
148
+ * @returns {Logger}
149
+ */
150
+ child: (childContext = {}) => {
151
+ return createLogger({
152
+ name,
153
+ level: LEVEL_NAMES[minLevel],
154
+ context: { ...defaultContext, ...childContext },
155
+ });
156
+ },
157
+ };
158
+ };
159
+
160
+ /**
161
+ * Creates a request logger middleware helper.
162
+ * Returns a function that accepts a request object and produces
163
+ * a child logger enriched with request-specific fields.
164
+ * @param {Logger} parentLogger - The parent logger instance
165
+ * @returns {(request: { method?: string, url?: string, headers?: Object }) => Logger}
166
+ */
167
+ export const requestLogger = (parentLogger) => {
168
+ return (request) => {
169
+ const requestId = request.headers?.["x-request-id"] || crypto.randomUUID();
170
+ const method = request.method || "UNKNOWN";
171
+ const path = request.url
172
+ ? new URL(request.url, "http://localhost").pathname
173
+ : "/";
174
+
175
+ const child = parentLogger.child({ requestId, method, path });
176
+ child.info("request started");
177
+ return child;
178
+ };
179
+ };
180
+
181
+ /** Default logger instance */
182
+ export const logger = createLogger({ name: "quark" });
@@ -0,0 +1,287 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, test } from "node:test";
3
+ import { createLogger, requestLogger } from "../src/logger.js";
4
+
5
+ /** Helper: captures console output during a callback */
6
+ const captureConsole = async (fn) => {
7
+ const output = { log: [], warn: [], error: [] };
8
+ const orig = {
9
+ log: console.log,
10
+ warn: console.warn,
11
+ error: console.error,
12
+ };
13
+
14
+ console.log = (...args) => output.log.push(args.join(" "));
15
+ console.warn = (...args) => output.warn.push(args.join(" "));
16
+ console.error = (...args) => output.error.push(args.join(" "));
17
+
18
+ try {
19
+ await fn();
20
+ } finally {
21
+ console.log = orig.log;
22
+ console.warn = orig.warn;
23
+ console.error = orig.error;
24
+ }
25
+ return output;
26
+ };
27
+
28
+ describe("Logger", () => {
29
+ let originalNodeEnv;
30
+ let originalLogLevel;
31
+
32
+ beforeEach(() => {
33
+ originalNodeEnv = process.env.NODE_ENV;
34
+ originalLogLevel = process.env.LOG_LEVEL;
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (originalNodeEnv === undefined) {
39
+ delete process.env.NODE_ENV;
40
+ } else {
41
+ process.env.NODE_ENV = originalNodeEnv;
42
+ }
43
+ if (originalLogLevel === undefined) {
44
+ delete process.env.LOG_LEVEL;
45
+ } else {
46
+ process.env.LOG_LEVEL = originalLogLevel;
47
+ }
48
+ });
49
+
50
+ test("creates logger with default options", () => {
51
+ const log = createLogger();
52
+ assert.ok(log.debug);
53
+ assert.ok(log.info);
54
+ assert.ok(log.warn);
55
+ assert.ok(log.error);
56
+ assert.ok(log.fatal);
57
+ assert.ok(log.child);
58
+ });
59
+
60
+ test("default logger instance exists with name 'quark'", async () => {
61
+ process.env.NODE_ENV = "production";
62
+ const log = createLogger({ name: "quark" });
63
+ const output = await captureConsole(() => {
64
+ log.info("hello");
65
+ });
66
+ const entry = JSON.parse(output.log[0]);
67
+ assert.equal(entry.name, "quark");
68
+ });
69
+
70
+ test("log level filtering: debug hidden at info level", async () => {
71
+ process.env.NODE_ENV = "production";
72
+ const log = createLogger({ name: "test", level: "info" });
73
+
74
+ const output = await captureConsole(() => {
75
+ log.debug("should be hidden");
76
+ log.info("should be visible");
77
+ });
78
+
79
+ assert.equal(output.log.length, 1);
80
+ const entry = JSON.parse(output.log[0]);
81
+ assert.equal(entry.msg, "should be visible");
82
+ });
83
+
84
+ test("log level filtering: debug visible at debug level", async () => {
85
+ process.env.NODE_ENV = "production";
86
+ const log = createLogger({ name: "test", level: "debug" });
87
+
88
+ const output = await captureConsole(() => {
89
+ log.debug("visible");
90
+ });
91
+
92
+ assert.equal(output.log.length, 1);
93
+ const entry = JSON.parse(output.log[0]);
94
+ assert.equal(entry.msg, "visible");
95
+ assert.equal(entry.level, "debug");
96
+ });
97
+
98
+ test("respects LOG_LEVEL env var", async () => {
99
+ process.env.NODE_ENV = "production";
100
+ process.env.LOG_LEVEL = "warn";
101
+ const log = createLogger({ name: "test" });
102
+
103
+ const output = await captureConsole(() => {
104
+ log.info("hidden");
105
+ log.warn("visible");
106
+ });
107
+
108
+ assert.equal(output.log.length, 0);
109
+ assert.equal(output.warn.length, 1);
110
+ const entry = JSON.parse(output.warn[0]);
111
+ assert.equal(entry.msg, "visible");
112
+ });
113
+
114
+ test("child logger inherits parent context", async () => {
115
+ process.env.NODE_ENV = "production";
116
+ const parent = createLogger({
117
+ name: "parent",
118
+ level: "info",
119
+ context: { service: "web" },
120
+ });
121
+
122
+ const child = parent.child({ requestId: "abc-123" });
123
+
124
+ const output = await captureConsole(() => {
125
+ child.info("child message");
126
+ });
127
+
128
+ const entry = JSON.parse(output.log[0]);
129
+ assert.equal(entry.name, "parent");
130
+ assert.equal(entry.service, "web");
131
+ assert.equal(entry.requestId, "abc-123");
132
+ assert.equal(entry.msg, "child message");
133
+ });
134
+
135
+ test("child logger merges context without mutating parent", async () => {
136
+ process.env.NODE_ENV = "production";
137
+ const parent = createLogger({
138
+ name: "parent",
139
+ level: "info",
140
+ context: { a: 1 },
141
+ });
142
+
143
+ parent.child({ b: 2 });
144
+
145
+ const output = await captureConsole(() => {
146
+ parent.info("parent only");
147
+ });
148
+
149
+ const entry = JSON.parse(output.log[0]);
150
+ assert.equal(entry.a, 1);
151
+ assert.equal(entry.b, undefined);
152
+ });
153
+
154
+ test("structured output contains required fields", async () => {
155
+ process.env.NODE_ENV = "production";
156
+ const log = createLogger({ name: "fields-test", level: "debug" });
157
+
158
+ const output = await captureConsole(() => {
159
+ log.info("test message", { extra: "data" });
160
+ });
161
+
162
+ const entry = JSON.parse(output.log[0]);
163
+ assert.ok(entry.timestamp);
164
+ assert.equal(entry.level, "info");
165
+ assert.equal(entry.name, "fields-test");
166
+ assert.equal(entry.msg, "test message");
167
+ assert.equal(entry.extra, "data");
168
+ // timestamp should be ISO 8601
169
+ assert.ok(!Number.isNaN(Date.parse(entry.timestamp)));
170
+ });
171
+
172
+ test("error and fatal use console.error", async () => {
173
+ process.env.NODE_ENV = "production";
174
+ const log = createLogger({ name: "test", level: "debug" });
175
+
176
+ const output = await captureConsole(() => {
177
+ log.error("err msg");
178
+ log.fatal("fatal msg");
179
+ });
180
+
181
+ assert.equal(output.error.length, 2);
182
+ const errEntry = JSON.parse(output.error[0]);
183
+ assert.equal(errEntry.level, "error");
184
+ const fatalEntry = JSON.parse(output.error[1]);
185
+ assert.equal(fatalEntry.level, "fatal");
186
+ });
187
+
188
+ test("warn uses console.warn", async () => {
189
+ process.env.NODE_ENV = "production";
190
+ const log = createLogger({ name: "test", level: "debug" });
191
+
192
+ const output = await captureConsole(() => {
193
+ log.warn("warn msg");
194
+ });
195
+
196
+ assert.equal(output.warn.length, 1);
197
+ const entry = JSON.parse(output.warn[0]);
198
+ assert.equal(entry.level, "warn");
199
+ });
200
+
201
+ test("dev mode outputs colorized non-JSON string", async () => {
202
+ delete process.env.NODE_ENV;
203
+ const log = createLogger({ name: "dev-test", level: "debug" });
204
+
205
+ const output = await captureConsole(() => {
206
+ log.info("hello dev");
207
+ });
208
+
209
+ assert.equal(output.log.length, 1);
210
+ const line = output.log[0];
211
+ // Should NOT be valid JSON in dev mode
212
+ assert.throws(() => JSON.parse(line));
213
+ // Should contain the message and logger name
214
+ assert.ok(line.includes("hello dev"));
215
+ assert.ok(line.includes("dev-test"));
216
+ assert.ok(line.includes("INFO"));
217
+ });
218
+
219
+ test("call-site data overrides default context", async () => {
220
+ process.env.NODE_ENV = "production";
221
+ const log = createLogger({
222
+ name: "test",
223
+ level: "info",
224
+ context: { region: "us-east" },
225
+ });
226
+
227
+ const output = await captureConsole(() => {
228
+ log.info("override", { region: "eu-west" });
229
+ });
230
+
231
+ const entry = JSON.parse(output.log[0]);
232
+ assert.equal(entry.region, "eu-west");
233
+ });
234
+ });
235
+
236
+ describe("requestLogger", () => {
237
+ test("creates child logger with request fields", async () => {
238
+ process.env.NODE_ENV = "production";
239
+ const parent = createLogger({ name: "web", level: "info" });
240
+ const getReqLogger = requestLogger(parent);
241
+
242
+ const request = {
243
+ method: "GET",
244
+ url: "/api/users?page=1",
245
+ headers: { "x-request-id": "req-456" },
246
+ };
247
+
248
+ const output = await captureConsole(() => {
249
+ const reqLog = getReqLogger(request);
250
+ reqLog.info("handling request");
251
+ });
252
+
253
+ // First log is "request started", second is "handling request"
254
+ assert.equal(output.log.length, 2);
255
+
256
+ const startEntry = JSON.parse(output.log[0]);
257
+ assert.equal(startEntry.msg, "request started");
258
+ assert.equal(startEntry.requestId, "req-456");
259
+ assert.equal(startEntry.method, "GET");
260
+ assert.equal(startEntry.path, "/api/users");
261
+
262
+ const handleEntry = JSON.parse(output.log[1]);
263
+ assert.equal(handleEntry.msg, "handling request");
264
+ assert.equal(handleEntry.requestId, "req-456");
265
+ });
266
+
267
+ test("generates requestId when header is missing", async () => {
268
+ process.env.NODE_ENV = "production";
269
+ const parent = createLogger({ name: "web", level: "info" });
270
+ const getReqLogger = requestLogger(parent);
271
+
272
+ const request = {
273
+ method: "POST",
274
+ url: "/api/data",
275
+ headers: {},
276
+ };
277
+
278
+ const output = await captureConsole(() => {
279
+ getReqLogger(request);
280
+ });
281
+
282
+ const entry = JSON.parse(output.log[0]);
283
+ assert.ok(entry.requestId);
284
+ assert.equal(entry.method, "POST");
285
+ assert.equal(entry.path, "/api/data");
286
+ });
287
+ });
package/src/mailhog.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Builds MAILHOG_SMTP_URL from individual environment variables if not explicitly provided.
3
+ * Allows configuration via individual MAILHOG_* vars instead of a single MAILHOG_SMTP_URL.
4
+ */
5
+ function getMailhogSmtpUrl() {
6
+ if (process.env.MAILHOG_SMTP_URL) {
7
+ return process.env.MAILHOG_SMTP_URL;
8
+ }
9
+
10
+ const host = process.env.MAILHOG_HOST || "localhost";
11
+ const port = process.env.MAILHOG_SMTP_PORT || "1025";
12
+
13
+ return `smtp://${host}:${port}`;
14
+ }
15
+
16
+ /**
17
+ * Builds Mailhog Web UI URL from individual environment variables.
18
+ * Useful for displaying the URL in logs or configuration.
19
+ */
20
+ function getMailhogUiUrl() {
21
+ const host = process.env.MAILHOG_HOST || "localhost";
22
+ const port = process.env.MAILHOG_UI_PORT || "8025";
23
+
24
+ return `http://${host}:${port}`;
25
+ }
26
+
27
+ /**
28
+ * Gets Mailhog SMTP configuration for email clients.
29
+ */
30
+ export const getMailhogSmtpConfig = () => {
31
+ const url = getMailhogSmtpUrl();
32
+ const match = url.match(/smtp:\/\/([^:]+):(\d+)/);
33
+ const host = match ? match[1] : (process.env.MAILHOG_HOST || "localhost");
34
+ const port = match ? match[2] : (process.env.MAILHOG_SMTP_PORT || "1025");
35
+
36
+ return {
37
+ host,
38
+ port: parseInt(port, 10),
39
+ url,
40
+ };
41
+ };
42
+
43
+ export { getMailhogSmtpUrl, getMailhogUiUrl };