@usequark/quark-core 2.5.4
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/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
detectMimeType,
|
|
5
|
+
isTypeAllowed,
|
|
6
|
+
validateFile,
|
|
7
|
+
} from "../src/file-validation.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// detectMimeType
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
test("FileValidation - detectMimeType identifies JPEG", () => {
|
|
14
|
+
const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]);
|
|
15
|
+
assert.strictEqual(detectMimeType(buf), "image/jpeg");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("FileValidation - detectMimeType identifies PNG", () => {
|
|
19
|
+
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
20
|
+
assert.strictEqual(detectMimeType(buf), "image/png");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("FileValidation - detectMimeType identifies GIF", () => {
|
|
24
|
+
const buf = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
|
|
25
|
+
assert.strictEqual(detectMimeType(buf), "image/gif");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("FileValidation - detectMimeType identifies WebP", () => {
|
|
29
|
+
const buf = Buffer.alloc(16);
|
|
30
|
+
// RIFF....WEBP
|
|
31
|
+
buf.writeUInt8(0x52, 0); // R
|
|
32
|
+
buf.writeUInt8(0x49, 1); // I
|
|
33
|
+
buf.writeUInt8(0x46, 2); // F
|
|
34
|
+
buf.writeUInt8(0x46, 3); // F
|
|
35
|
+
buf.writeUInt8(0x57, 8); // W
|
|
36
|
+
buf.writeUInt8(0x45, 9); // E
|
|
37
|
+
buf.writeUInt8(0x42, 10); // B
|
|
38
|
+
buf.writeUInt8(0x50, 11); // P
|
|
39
|
+
assert.strictEqual(detectMimeType(buf), "image/webp");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("FileValidation - detectMimeType identifies PDF", () => {
|
|
43
|
+
const buf = Buffer.from("%PDF-1.5", "ascii");
|
|
44
|
+
assert.strictEqual(detectMimeType(buf), "application/pdf");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("FileValidation - detectMimeType identifies SVG", () => {
|
|
48
|
+
const buf = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
49
|
+
assert.strictEqual(detectMimeType(buf), "image/svg+xml");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("FileValidation - detectMimeType identifies SVG with XML declaration", () => {
|
|
53
|
+
const buf = Buffer.from('<?xml version="1.0"?><svg></svg>');
|
|
54
|
+
assert.strictEqual(detectMimeType(buf), "image/svg+xml");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("FileValidation - detectMimeType identifies ZIP", () => {
|
|
58
|
+
const buf = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0]);
|
|
59
|
+
assert.strictEqual(detectMimeType(buf), "application/zip");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("FileValidation - detectMimeType identifies WebM", () => {
|
|
63
|
+
const buf = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0, 0]);
|
|
64
|
+
assert.strictEqual(detectMimeType(buf), "video/webm");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("FileValidation - detectMimeType returns null for unknown bytes", () => {
|
|
68
|
+
const buf = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
|
69
|
+
assert.strictEqual(detectMimeType(buf), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// isTypeAllowed
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
test("FileValidation - isTypeAllowed exact match", () => {
|
|
77
|
+
assert.strictEqual(
|
|
78
|
+
isTypeAllowed("image/jpeg", ["image/jpeg", "image/png"]),
|
|
79
|
+
true,
|
|
80
|
+
);
|
|
81
|
+
assert.strictEqual(
|
|
82
|
+
isTypeAllowed("image/gif", ["image/jpeg", "image/png"]),
|
|
83
|
+
false,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("FileValidation - isTypeAllowed wildcard category match", () => {
|
|
88
|
+
assert.strictEqual(isTypeAllowed("image/jpeg", ["image/*"]), true);
|
|
89
|
+
assert.strictEqual(isTypeAllowed("image/png", ["image/*"]), true);
|
|
90
|
+
assert.strictEqual(isTypeAllowed("application/pdf", ["image/*"]), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("FileValidation - isTypeAllowed universal wildcard", () => {
|
|
94
|
+
assert.strictEqual(isTypeAllowed("video/mp4", ["*/*"]), true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("FileValidation - isTypeAllowed is case insensitive", () => {
|
|
98
|
+
assert.strictEqual(isTypeAllowed("Image/JPEG", ["image/jpeg"]), true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("FileValidation - isTypeAllowed returns false for null/empty mime", () => {
|
|
102
|
+
assert.strictEqual(isTypeAllowed(null, ["image/jpeg"]), false);
|
|
103
|
+
assert.strictEqual(isTypeAllowed("", ["image/jpeg"]), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// validateFile
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
test("FileValidation - validateFile accepts valid file", () => {
|
|
111
|
+
const pngMagic = Buffer.from([
|
|
112
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
113
|
+
]);
|
|
114
|
+
const result = validateFile(
|
|
115
|
+
{
|
|
116
|
+
filename: "test.png",
|
|
117
|
+
mimeType: "image/png",
|
|
118
|
+
size: 1024,
|
|
119
|
+
buffer: pngMagic,
|
|
120
|
+
},
|
|
121
|
+
{ allowedTypes: ["image/*"] },
|
|
122
|
+
);
|
|
123
|
+
assert.strictEqual(result.valid, true);
|
|
124
|
+
assert.strictEqual(result.detectedType, "image/png");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("FileValidation - validateFile rejects oversized file", () => {
|
|
128
|
+
const result = validateFile(
|
|
129
|
+
{ filename: "big.jpg", mimeType: "image/jpeg", size: 20 * 1024 * 1024 },
|
|
130
|
+
{ maxSize: 10 * 1024 * 1024 },
|
|
131
|
+
);
|
|
132
|
+
assert.strictEqual(result.valid, false);
|
|
133
|
+
assert.match(result.error, /exceeds maximum size/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("FileValidation - validateFile rejects disallowed MIME type", () => {
|
|
137
|
+
const result = validateFile(
|
|
138
|
+
{ filename: "virus.exe", mimeType: "application/x-msdownload", size: 100 },
|
|
139
|
+
{ allowedTypes: ["image/*"] },
|
|
140
|
+
);
|
|
141
|
+
assert.strictEqual(result.valid, false);
|
|
142
|
+
assert.match(result.error, /not allowed/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("FileValidation - validateFile detects MIME spoofing", () => {
|
|
146
|
+
// Claim it's an image/jpeg but magic bytes say PDF
|
|
147
|
+
const pdfMagic = Buffer.from("%PDF-1.5", "ascii");
|
|
148
|
+
const result = validateFile(
|
|
149
|
+
{
|
|
150
|
+
filename: "fake.jpg",
|
|
151
|
+
mimeType: "image/jpeg",
|
|
152
|
+
size: 100,
|
|
153
|
+
buffer: pdfMagic,
|
|
154
|
+
},
|
|
155
|
+
{ allowedTypes: ["image/jpeg", "application/pdf"] },
|
|
156
|
+
);
|
|
157
|
+
assert.strictEqual(result.valid, false);
|
|
158
|
+
assert.match(result.error, /does not match declared type/);
|
|
159
|
+
assert.strictEqual(result.detectedType, "application/pdf");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("FileValidation - validateFile skips magic bytes when disabled", () => {
|
|
163
|
+
const pdfMagic = Buffer.from("%PDF-1.5", "ascii");
|
|
164
|
+
const result = validateFile(
|
|
165
|
+
{
|
|
166
|
+
filename: "file.jpg",
|
|
167
|
+
mimeType: "image/jpeg",
|
|
168
|
+
size: 100,
|
|
169
|
+
buffer: pdfMagic,
|
|
170
|
+
},
|
|
171
|
+
{ allowedTypes: ["image/jpeg"], verifyMagicBytes: false },
|
|
172
|
+
);
|
|
173
|
+
assert.strictEqual(result.valid, true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("FileValidation - validateFile passes when magic bytes unknown", () => {
|
|
177
|
+
// Unknown magic bytes should not block the file
|
|
178
|
+
const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
|
|
179
|
+
const result = validateFile(
|
|
180
|
+
{
|
|
181
|
+
filename: "data.bin",
|
|
182
|
+
mimeType: "application/pdf",
|
|
183
|
+
size: 100,
|
|
184
|
+
buffer: buf,
|
|
185
|
+
},
|
|
186
|
+
{ allowedTypes: ["application/pdf"] },
|
|
187
|
+
);
|
|
188
|
+
assert.strictEqual(result.valid, true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("FileValidation - validateFile works without buffer", () => {
|
|
192
|
+
const result = validateFile(
|
|
193
|
+
{ filename: "file.png", mimeType: "image/png", size: 100 },
|
|
194
|
+
{ allowedTypes: ["image/*"] },
|
|
195
|
+
);
|
|
196
|
+
assert.strictEqual(result.valid, true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("FileValidation - validateFile uses env defaults gracefully", () => {
|
|
200
|
+
// No options → uses module defaults
|
|
201
|
+
const result = validateFile({
|
|
202
|
+
filename: "test.jpg",
|
|
203
|
+
mimeType: "image/jpeg",
|
|
204
|
+
size: 1024,
|
|
205
|
+
});
|
|
206
|
+
assert.strictEqual(result.valid, true);
|
|
207
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Authorization exports
|
|
2
|
+
|
|
3
|
+
// Admin exports
|
|
4
|
+
export * from "./admin.js";
|
|
5
|
+
// Admin auth exports
|
|
6
|
+
export * from "./admin-auth.js";
|
|
7
|
+
// Auth exports
|
|
8
|
+
export * from "./auth/index.js";
|
|
9
|
+
export * from "./authorization.js";
|
|
10
|
+
// Cache exports
|
|
11
|
+
export * from "./cache.js";
|
|
12
|
+
// CSRF protection exports
|
|
13
|
+
export * from "./csrf.js";
|
|
14
|
+
// Database exports
|
|
15
|
+
export * from "./db.js";
|
|
16
|
+
// Database instrumentation exports
|
|
17
|
+
export * from "./db-instrumentation.js";
|
|
18
|
+
// Email service exports
|
|
19
|
+
export * from "./email.js";
|
|
20
|
+
// Email template exports
|
|
21
|
+
export * from "./email-templates.js";
|
|
22
|
+
// Error Reporter exports
|
|
23
|
+
export * from "./error-reporter.js";
|
|
24
|
+
// Error exports
|
|
25
|
+
export * from "./errors.js";
|
|
26
|
+
// File validation exports
|
|
27
|
+
export * from "./file-validation.js";
|
|
28
|
+
// Logger exports
|
|
29
|
+
export * from "./logger.js";
|
|
30
|
+
// Mail exports
|
|
31
|
+
export * from "./mail.js";
|
|
32
|
+
// Metrics exports
|
|
33
|
+
export * from "./metrics.js";
|
|
34
|
+
// Multipart parsing exports
|
|
35
|
+
export * from "./multipart.js";
|
|
36
|
+
// Pagination exports
|
|
37
|
+
export * from "./pagination.js";
|
|
38
|
+
// Query builder exports
|
|
39
|
+
export * from "./query-builder.js";
|
|
40
|
+
// Queue exports
|
|
41
|
+
export * from "./queue/index.js";
|
|
42
|
+
// Rate limiting exports
|
|
43
|
+
export * from "./rate-limiter.js";
|
|
44
|
+
// Redis exports
|
|
45
|
+
export * from "./redis.js";
|
|
46
|
+
// Request logger exports
|
|
47
|
+
export * from "./request-logger.js";
|
|
48
|
+
// SMS service exports
|
|
49
|
+
export * from "./sms.js";
|
|
50
|
+
// Storage exports
|
|
51
|
+
export * from "./storage.js";
|
|
52
|
+
|
|
53
|
+
// Utility exports
|
|
54
|
+
export * from "./utils.js";
|
|
55
|
+
export * from "./validation.js";
|
|
56
|
+
|
|
57
|
+
// Testing utilities (import from "@techstream/quark-core/testing" in test files)
|
|
58
|
+
// Not re-exported from main to avoid polluting production imports
|
package/src/locale.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const DEFAULT_LOCALE_FALLBACK = "en";
|
|
2
|
+
const SUPPORTED_LOCALES_FALLBACK = ["en"];
|
|
3
|
+
|
|
4
|
+
export function getDefaultLocale() {
|
|
5
|
+
const env = process.env.DEFAULT_LOCALE;
|
|
6
|
+
return env?.trim() ? env.trim() : DEFAULT_LOCALE_FALLBACK;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getSupportedLocales() {
|
|
10
|
+
const env = process.env.SUPPORTED_LOCALES;
|
|
11
|
+
if (!env?.trim()) return SUPPORTED_LOCALES_FALLBACK;
|
|
12
|
+
return env
|
|
13
|
+
.split(",")
|
|
14
|
+
.map((l) => l.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isLocaleSupported(locale) {
|
|
19
|
+
if (!locale || typeof locale !== "string") return false;
|
|
20
|
+
return getSupportedLocales().includes(locale.trim());
|
|
21
|
+
}
|
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" });
|