@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.
Files changed (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import {
4
+ createRateLimiter,
5
+ createRateLimitMiddleware,
6
+ RATE_LIMIT_PRESETS,
7
+ } from "../src/rate-limiter.js";
8
+
9
+ test("Rate Limiter Module", async (t) => {
10
+ await t.test("createRateLimiter creates memory limiter by default", () => {
11
+ const limiter = createRateLimiter();
12
+ assert(limiter !== null);
13
+ });
14
+
15
+ await t.test(
16
+ "createRateLimiter throws error for Redis without client",
17
+ () => {
18
+ assert.throws(
19
+ () => createRateLimiter({ type: "redis" }),
20
+ /Redis client is required/,
21
+ );
22
+ },
23
+ );
24
+
25
+ await t.test("Memory rate limiter allows requests under limit", async () => {
26
+ const limiter = createRateLimiter({ type: "memory" });
27
+ const result = await limiter.checkLimit("test-key", 5, 60000);
28
+
29
+ assert.strictEqual(result.limited, false);
30
+ assert.strictEqual(result.remaining, 4);
31
+ assert(result.resetTime > Date.now());
32
+ });
33
+
34
+ await t.test("Memory rate limiter blocks requests over limit", async () => {
35
+ const limiter = createRateLimiter({ type: "memory" });
36
+
37
+ // Make 5 requests (limit is 5)
38
+ for (let i = 0; i < 5; i++) {
39
+ await limiter.checkLimit("test-key-2", 5, 60000);
40
+ }
41
+
42
+ // 6th request should be blocked
43
+ const result = await limiter.checkLimit("test-key-2", 5, 60000);
44
+ assert.strictEqual(result.limited, true);
45
+ assert.strictEqual(result.remaining, 0);
46
+ });
47
+
48
+ await t.test("Memory rate limiter resets after window", async () => {
49
+ const limiter = createRateLimiter({ type: "memory" });
50
+
51
+ // Make a request with very short window
52
+ const result1 = await limiter.checkLimit("test-key-3", 1, 10);
53
+ assert.strictEqual(result1.limited, false);
54
+
55
+ // Second request should be blocked
56
+ const result2 = await limiter.checkLimit("test-key-3", 1, 10);
57
+ assert.strictEqual(result2.limited, true);
58
+
59
+ // Wait for window to expire
60
+ await new Promise((resolve) => setTimeout(resolve, 20));
61
+
62
+ // Should be allowed again
63
+ const result3 = await limiter.checkLimit("test-key-3", 1, 10);
64
+ assert.strictEqual(result3.limited, false);
65
+ });
66
+
67
+ await t.test("Memory rate limiter can reset a key", async () => {
68
+ const limiter = createRateLimiter({ type: "memory" });
69
+
70
+ await limiter.checkLimit("test-key-4", 1, 60000);
71
+ await limiter.reset("test-key-4");
72
+
73
+ const result = await limiter.checkLimit("test-key-4", 1, 60000);
74
+ assert.strictEqual(result.limited, false);
75
+ });
76
+
77
+ await t.test("Memory rate limiter can clear all keys", async () => {
78
+ const limiter = createRateLimiter({ type: "memory" });
79
+
80
+ await limiter.checkLimit("key-1", 1, 60000);
81
+ await limiter.checkLimit("key-2", 1, 60000);
82
+ await limiter.clear();
83
+
84
+ const result1 = await limiter.checkLimit("key-1", 1, 60000);
85
+ const result2 = await limiter.checkLimit("key-2", 1, 60000);
86
+
87
+ assert.strictEqual(result1.limited, false);
88
+ assert.strictEqual(result2.limited, false);
89
+ });
90
+
91
+ await t.test("RATE_LIMIT_PRESETS have correct structure", () => {
92
+ assert(RATE_LIMIT_PRESETS.strict);
93
+ assert(RATE_LIMIT_PRESETS.moderate);
94
+ assert(RATE_LIMIT_PRESETS.relaxed);
95
+ assert(RATE_LIMIT_PRESETS.auth);
96
+ assert(RATE_LIMIT_PRESETS.api);
97
+
98
+ assert.strictEqual(RATE_LIMIT_PRESETS.auth.maxRequests, 5);
99
+ assert.strictEqual(RATE_LIMIT_PRESETS.api.maxRequests, 100);
100
+ });
101
+
102
+ await t.test("createRateLimitMiddleware returns a function", () => {
103
+ const limiter = createRateLimiter({ type: "memory" });
104
+ const middleware = createRateLimitMiddleware(limiter);
105
+
106
+ assert.strictEqual(typeof middleware, "function");
107
+ });
108
+
109
+ await t.test("createRateLimitMiddleware works with API preset", async () => {
110
+ const limiter = createRateLimiter({ type: "memory" });
111
+ const middleware = createRateLimitMiddleware(
112
+ limiter,
113
+ RATE_LIMIT_PRESETS.api,
114
+ );
115
+
116
+ const result = await middleware("192.168.1.1", "/api/posts");
117
+
118
+ assert.strictEqual(result.limited, false);
119
+ assert(result.remaining > 0);
120
+ });
121
+
122
+ await t.test("Cleanup interval is started for memory limiter", () => {
123
+ const limiter = createRateLimiter({ type: "memory" });
124
+ assert(limiter.cleanupInterval !== null);
125
+
126
+ // Clean up
127
+ limiter.stopCleanup();
128
+ assert(limiter.cleanupInterval === null);
129
+ });
130
+ });
package/src/redis.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Builds REDIS_URL from individual environment variables if not explicitly provided.
3
+ * Allows configuration via individual REDIS_* vars instead of a single REDIS_URL.
4
+ */
5
+ function getRedisUrl() {
6
+ if (process.env.REDIS_URL) {
7
+ return process.env.REDIS_URL;
8
+ }
9
+
10
+ const host = process.env.REDIS_HOST || "localhost";
11
+ const port = process.env.REDIS_PORT || "6379";
12
+
13
+ return `redis://${host}:${port}`;
14
+ }
15
+
16
+ /**
17
+ * Creates a Redis configuration object from environment variables.
18
+ * Returns the URL and any additional options - does not create an actual connection.
19
+ * Pass the URL to your Redis library of choice (e.g., ioredis).
20
+ *
21
+ * @deprecated Use `resolveRedisConnection` for ioredis/BullMQ configs, or `getRedisUrl` for URL strings.
22
+ */
23
+ export const createRedisConfig = (options = {}) => {
24
+ const redisUrl = getRedisUrl();
25
+
26
+ return {
27
+ url: redisUrl,
28
+ ...options,
29
+ };
30
+ };
31
+
32
+ /**
33
+ * @deprecated Use `resolveRedisConnection` instead.
34
+ */
35
+ export const createRedisClient = createRedisConfig;
36
+
37
+ /**
38
+ * Pings Redis to verify connectivity.
39
+ * Dynamically imports ioredis so it doesn't fail at load time if the package is not installed.
40
+ * Creates a temporary connection, sends PING, expects PONG, then disconnects.
41
+ *
42
+ * @param {object} [options]
43
+ * @param {number} [options.timeout=3000] - Connection/ping timeout in milliseconds.
44
+ * @returns {Promise<{ status: "ok", latencyMs: number } | { status: "error", message: string }>}
45
+ */
46
+ export async function pingRedis({ timeout = 3000 } = {}) {
47
+ /** @type {import("ioredis").default | null} */
48
+ let client = null;
49
+
50
+ try {
51
+ const { default: Redis } = await import("ioredis");
52
+
53
+ const conn = resolveRedisConnection();
54
+
55
+ client = new Redis({
56
+ ...conn,
57
+ lazyConnect: true,
58
+ connectTimeout: timeout,
59
+ maxRetriesPerRequest: 0,
60
+ enableReadyCheck: false,
61
+ });
62
+
63
+ // Suppress the "Unhandled error event" stderr noise that ioredis emits
64
+ // when a connection attempt fails. The error is still caught by the
65
+ // try/catch below - this listener just prevents Node from treating it
66
+ // as an unhandled EventEmitter error.
67
+ client.on("error", () => {});
68
+
69
+ await client.connect();
70
+
71
+ const start = performance.now();
72
+ const result = await new Promise((resolve, reject) => {
73
+ const timer = setTimeout(
74
+ () => reject(new Error("PING timed out")),
75
+ timeout,
76
+ );
77
+ client.ping().then(
78
+ (val) => {
79
+ clearTimeout(timer);
80
+ resolve(val);
81
+ },
82
+ (err) => {
83
+ clearTimeout(timer);
84
+ reject(err);
85
+ },
86
+ );
87
+ });
88
+
89
+ const latencyMs = Math.round(performance.now() - start);
90
+
91
+ if (result !== "PONG") {
92
+ return {
93
+ status: "error",
94
+ message: `Unexpected PING response: ${result}`,
95
+ };
96
+ }
97
+
98
+ return { status: "ok", latencyMs };
99
+ } catch (/** @type {any} */ error) {
100
+ let message;
101
+ if (
102
+ error?.code === "MODULE_NOT_FOUND" ||
103
+ error?.code === "ERR_MODULE_NOT_FOUND"
104
+ ) {
105
+ message = "ioredis is not installed";
106
+ } else if (
107
+ error?.code === "ECONNREFUSED" ||
108
+ /connection is closed|ECONNREFUSED/i.test(error?.message ?? "")
109
+ ) {
110
+ message = `Redis unreachable at ${getRedisUrl()}`;
111
+ } else {
112
+ message = error?.message ?? String(error);
113
+ }
114
+ return { status: "error", message };
115
+ } finally {
116
+ try {
117
+ await client?.disconnect();
118
+ } catch {
119
+ // ignore disconnect errors
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Resolves Redis connection config as an ioredis-compatible options object.
126
+ * Precedence: explicit override > REDIS_URL > REDIS_HOST/REDIS_PORT > localhost defaults.
127
+ *
128
+ * @param {object} [override] - if provided, returned as-is (pass-through for caller-supplied config)
129
+ * @returns {{ host: string, port: number, password?: string, tls?: object }}
130
+ */
131
+ export function resolveRedisConnection(override) {
132
+ if (override) return override;
133
+
134
+ if (process.env.REDIS_URL) {
135
+ try {
136
+ const u = new URL(process.env.REDIS_URL);
137
+ return {
138
+ host: u.hostname,
139
+ port: parseInt(u.port || "6379", 10),
140
+ ...(u.password && { password: decodeURIComponent(u.password) }),
141
+ ...(u.protocol === "rediss:" && { tls: {} }),
142
+ };
143
+ } catch {
144
+ // Malformed REDIS_URL - fall through to host/port env vars
145
+ }
146
+ }
147
+
148
+ return {
149
+ host: process.env.REDIS_HOST || "localhost",
150
+ port: parseInt(process.env.REDIS_PORT || "6379", 10),
151
+ };
152
+ }
153
+
154
+ export { getRedisUrl };
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import { getRedisUrl, resolveRedisConnection } from "./redis.js";
4
+
5
+ describe("getRedisUrl", () => {
6
+ let originalEnv;
7
+
8
+ beforeEach(() => {
9
+ originalEnv = { ...process.env };
10
+ });
11
+
12
+ afterEach(() => {
13
+ process.env = originalEnv;
14
+ });
15
+
16
+ it("returns REDIS_URL when set", () => {
17
+ process.env.REDIS_URL = "redis://custom:6380";
18
+ delete process.env.REDIS_HOST;
19
+ delete process.env.REDIS_PORT;
20
+
21
+ assert.equal(getRedisUrl(), "redis://custom:6380");
22
+ });
23
+
24
+ it("builds URL from REDIS_HOST and REDIS_PORT", () => {
25
+ delete process.env.REDIS_URL;
26
+ process.env.REDIS_HOST = "myhost";
27
+ process.env.REDIS_PORT = "6381";
28
+
29
+ assert.equal(getRedisUrl(), "redis://myhost:6381");
30
+ });
31
+
32
+ it("defaults to localhost:6379", () => {
33
+ delete process.env.REDIS_URL;
34
+ delete process.env.REDIS_HOST;
35
+ delete process.env.REDIS_PORT;
36
+
37
+ assert.equal(getRedisUrl(), "redis://localhost:6379");
38
+ });
39
+
40
+ it("REDIS_URL takes precedence over REDIS_HOST/REDIS_PORT", () => {
41
+ process.env.REDIS_URL = "redis://from-url:9999";
42
+ process.env.REDIS_HOST = "from-host";
43
+ process.env.REDIS_PORT = "1111";
44
+
45
+ assert.equal(getRedisUrl(), "redis://from-url:9999");
46
+ });
47
+ });
48
+
49
+ describe("resolveRedisConnection", () => {
50
+ let originalEnv;
51
+
52
+ beforeEach(() => {
53
+ originalEnv = { ...process.env };
54
+ });
55
+
56
+ afterEach(() => {
57
+ process.env = originalEnv;
58
+ });
59
+
60
+ it("returns override as-is when provided", () => {
61
+ const override = { host: "custom", port: 1234 };
62
+ assert.deepEqual(resolveRedisConnection(override), override);
63
+ });
64
+
65
+ it("parses REDIS_URL into host/port", () => {
66
+ process.env.REDIS_URL = "redis://myredis:6380";
67
+ delete process.env.REDIS_HOST;
68
+ delete process.env.REDIS_PORT;
69
+
70
+ const result = resolveRedisConnection();
71
+ assert.equal(result.host, "myredis");
72
+ assert.equal(result.port, 6380);
73
+ });
74
+
75
+ it("parses password from REDIS_URL", () => {
76
+ process.env.REDIS_URL = "redis://:s3cret@myredis:6380";
77
+ delete process.env.REDIS_HOST;
78
+
79
+ const result = resolveRedisConnection();
80
+ assert.equal(result.host, "myredis");
81
+ assert.equal(result.port, 6380);
82
+ assert.equal(result.password, "s3cret");
83
+ });
84
+
85
+ it("decodes URL-encoded password", () => {
86
+ process.env.REDIS_URL = "redis://:p%40ss%3Aword@myredis:6380";
87
+ delete process.env.REDIS_HOST;
88
+
89
+ const result = resolveRedisConnection();
90
+ assert.equal(result.password, "p@ss:word");
91
+ });
92
+
93
+ it("sets tls for rediss:// protocol", () => {
94
+ process.env.REDIS_URL = "rediss://secure-host:6380";
95
+ delete process.env.REDIS_HOST;
96
+
97
+ const result = resolveRedisConnection();
98
+ assert.equal(result.host, "secure-host");
99
+ assert.deepEqual(result.tls, {});
100
+ });
101
+
102
+ it("defaults port to 6379 when REDIS_URL omits port", () => {
103
+ process.env.REDIS_URL = "redis://myredis";
104
+ delete process.env.REDIS_HOST;
105
+
106
+ const result = resolveRedisConnection();
107
+ assert.equal(result.port, 6379);
108
+ });
109
+
110
+ it("falls back to REDIS_HOST/REDIS_PORT when REDIS_URL is absent", () => {
111
+ delete process.env.REDIS_URL;
112
+ process.env.REDIS_HOST = "fallback-host";
113
+ process.env.REDIS_PORT = "7777";
114
+
115
+ const result = resolveRedisConnection();
116
+ assert.equal(result.host, "fallback-host");
117
+ assert.equal(result.port, 7777);
118
+ assert.equal(result.password, undefined);
119
+ });
120
+
121
+ it("defaults to localhost:6379 when no env vars are set", () => {
122
+ delete process.env.REDIS_URL;
123
+ delete process.env.REDIS_HOST;
124
+ delete process.env.REDIS_PORT;
125
+
126
+ const result = resolveRedisConnection();
127
+ assert.equal(result.host, "localhost");
128
+ assert.equal(result.port, 6379);
129
+ });
130
+
131
+ it("REDIS_URL takes precedence over REDIS_HOST/REDIS_PORT", () => {
132
+ process.env.REDIS_URL = "redis://url-host:8888";
133
+ process.env.REDIS_HOST = "host-var";
134
+ process.env.REDIS_PORT = "9999";
135
+
136
+ const result = resolveRedisConnection();
137
+ assert.equal(result.host, "url-host");
138
+ assert.equal(result.port, 8888);
139
+ });
140
+
141
+ it("falls back to REDIS_HOST/REDIS_PORT when REDIS_URL is malformed", () => {
142
+ process.env.REDIS_URL = "not-a-valid-url";
143
+ process.env.REDIS_HOST = "fallback-host";
144
+ process.env.REDIS_PORT = "6382";
145
+
146
+ const result = resolveRedisConnection();
147
+ assert.equal(result.host, "fallback-host");
148
+ assert.equal(result.port, 6382);
149
+ });
150
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Request/Response Logging Middleware
3
+ *
4
+ * Logs HTTP request and response details for debugging and auditing.
5
+ * Sanitizes sensitive data (passwords, tokens) from request bodies.
6
+ * Logs response bodies only for errors (4xx/5xx status codes).
7
+ */
8
+
9
+ import { logger } from "./logger.js";
10
+
11
+ /**
12
+ * Fields to sanitize from request bodies (replace with "[REDACTED]")
13
+ */
14
+ const SENSITIVE_FIELDS = [
15
+ "password",
16
+ "token",
17
+ "secret",
18
+ "apiKey",
19
+ "api_key",
20
+ "authorization",
21
+ "passwordConfirmation",
22
+ "oldPassword",
23
+ "newPassword",
24
+ "currentPassword",
25
+ ];
26
+
27
+ /**
28
+ * Recursively sanitize sensitive fields in an object
29
+ * @param {any} obj - Object to sanitize
30
+ * @param {string[]} fields - List of sensitive field names
31
+ * @returns {any} Sanitized object
32
+ */
33
+ function sanitizeObject(obj, fields = SENSITIVE_FIELDS) {
34
+ if (!obj || typeof obj !== "object") {
35
+ return obj;
36
+ }
37
+
38
+ if (Array.isArray(obj)) {
39
+ return obj.map((item) => sanitizeObject(item, fields));
40
+ }
41
+
42
+ const sanitized = {};
43
+ for (const [key, value] of Object.entries(obj)) {
44
+ const keyLower = key.toLowerCase();
45
+ if (fields.some((field) => keyLower.includes(field.toLowerCase()))) {
46
+ sanitized[key] = "[REDACTED]";
47
+ } else if (typeof value === "object" && value !== null) {
48
+ sanitized[key] = sanitizeObject(value, fields);
49
+ } else {
50
+ sanitized[key] = value;
51
+ }
52
+ }
53
+ return sanitized;
54
+ }
55
+
56
+ /**
57
+ * Create request logger options
58
+ * @typedef {Object} RequestLoggerOptions
59
+ * @property {boolean} [logRequestBody=true] - Log request body
60
+ * @property {boolean} [logResponseBody=false] - Log response body for all requests
61
+ * @property {boolean} [logErrorResponseBody=true] - Log response body only for errors
62
+ * @property {string[]} [sensitiveFields] - Custom sensitive field names to sanitize
63
+ * @property {number} [maxBodyLength=1000] - Max characters to log from body (prevents massive logs)
64
+ */
65
+
66
+ /**
67
+ * Create a request logger middleware for Next.js API routes
68
+ *
69
+ * @param {RequestLoggerOptions} [options={}] - Logging options
70
+ * @returns {Function} Middleware function
71
+ *
72
+ * @example
73
+ * import { createRequestLogger } from "@techstream/quark-core";
74
+ *
75
+ * const logRequest = createRequestLogger({
76
+ * logRequestBody: true,
77
+ * logErrorResponseBody: true,
78
+ * });
79
+ *
80
+ * export async function GET(request) {
81
+ * return logRequest(request, async () => {
82
+ * // Your handler logic
83
+ * return NextResponse.json({ data: "..." });
84
+ * });
85
+ * }
86
+ */
87
+ export function createRequestLogger(options = {}) {
88
+ const {
89
+ logRequestBody = true,
90
+ logResponseBody = false,
91
+ logErrorResponseBody = true,
92
+ sensitiveFields = SENSITIVE_FIELDS,
93
+ maxBodyLength = 1000,
94
+ } = options;
95
+
96
+ return async (request, handler) => {
97
+ const startTime = Date.now();
98
+ const { method, url } = request;
99
+ const { pathname, searchParams } = new URL(url);
100
+
101
+ // Build log context
102
+ const logContext = {
103
+ method,
104
+ path: pathname,
105
+ query: Object.fromEntries(searchParams),
106
+ headers: {
107
+ "user-agent": request.headers.get("user-agent"),
108
+ "content-type": request.headers.get("content-type"),
109
+ },
110
+ };
111
+
112
+ // Log request body for non-GET requests
113
+ if (logRequestBody && method !== "GET" && method !== "HEAD") {
114
+ try {
115
+ // Clone request to read body without consuming it
116
+ const clonedRequest = request.clone();
117
+ const contentType = request.headers.get("content-type") || "";
118
+
119
+ if (contentType.includes("application/json")) {
120
+ const body = await clonedRequest.json();
121
+ const sanitized = sanitizeObject(body, sensitiveFields);
122
+ const bodyStr = JSON.stringify(sanitized);
123
+ logContext.body =
124
+ bodyStr.length > maxBodyLength
125
+ ? `${bodyStr.substring(0, maxBodyLength)}... [truncated]`
126
+ : bodyStr;
127
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
128
+ const text = await clonedRequest.text();
129
+ const params = new URLSearchParams(text);
130
+ const paramsObj = Object.fromEntries(params);
131
+ const sanitized = sanitizeObject(paramsObj, sensitiveFields);
132
+ logContext.body = sanitized;
133
+ } else {
134
+ logContext.body = `[${contentType || "unknown content-type"}]`;
135
+ }
136
+ } catch (error) {
137
+ logContext.bodyError = error.message;
138
+ }
139
+ }
140
+
141
+ logger.info("Incoming request", logContext);
142
+
143
+ let response;
144
+ let responseError = null;
145
+
146
+ try {
147
+ // Execute the handler
148
+ response = await handler(request);
149
+ } catch (error) {
150
+ responseError = error;
151
+ throw error; // Re-throw to maintain error flow
152
+ } finally {
153
+ const duration = Date.now() - startTime;
154
+
155
+ const responseLog = {
156
+ method,
157
+ path: pathname,
158
+ duration: `${duration}ms`,
159
+ };
160
+
161
+ if (response) {
162
+ responseLog.status = response.status;
163
+
164
+ // Log response body for errors or if explicitly enabled
165
+ const isError = response.status >= 400;
166
+ const shouldLogBody =
167
+ logResponseBody || (logErrorResponseBody && isError);
168
+
169
+ if (shouldLogBody) {
170
+ try {
171
+ const clonedResponse = response.clone();
172
+ const contentType = response.headers.get("content-type") || "";
173
+
174
+ if (contentType.includes("application/json")) {
175
+ const body = await clonedResponse.json();
176
+ const bodyStr = JSON.stringify(body);
177
+ responseLog.body =
178
+ bodyStr.length > maxBodyLength
179
+ ? `${bodyStr.substring(0, maxBodyLength)}... [truncated]`
180
+ : bodyStr;
181
+ }
182
+ } catch (error) {
183
+ responseLog.bodyError = error.message;
184
+ }
185
+ }
186
+
187
+ if (isError) {
188
+ logger.error("Request completed with error", responseLog);
189
+ } else {
190
+ logger.info("Request completed", responseLog);
191
+ }
192
+ } else if (responseError) {
193
+ responseLog.error = responseError.message;
194
+ responseLog.stack = responseError.stack;
195
+ logger.error("Request failed", responseLog);
196
+ }
197
+ }
198
+
199
+ return response;
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Default request logger instance
205
+ */
206
+ export const logRequest = createRequestLogger();
207
+
208
+ /**
209
+ * Export sanitization utility for standalone use
210
+ */
211
+ export { sanitizeObject };