@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/.turbo/turbo-lint.log +7 -0
- package/.turbo/turbo-test.log +1376 -0
- package/README.md +419 -0
- package/package.json +29 -0
- package/src/auth/index.js +127 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +90 -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/csrf.js +118 -0
- package/src/csrf.test.js +157 -0
- package/src/email.js +140 -0
- package/src/email.test.js +259 -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/index.js +32 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mailhog.js +43 -0
- package/src/queue/index.js +214 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +96 -0
- package/src/testing/factories.js +93 -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
- package/test-imports.mjs +21 -0
package/src/utils.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @techstream/quark-core - Utility Functions
|
|
5
|
+
* Common utility functions used across the platform
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Retries an async function with exponential backoff
|
|
10
|
+
* @param {Function} fn - Async function to retry
|
|
11
|
+
* @param {Object} options - Retry options
|
|
12
|
+
* @param {number} options.maxAttempts - Maximum number of attempts (default: 3)
|
|
13
|
+
* @param {number} options.initialDelay - Initial delay in ms (default: 1000)
|
|
14
|
+
* @param {number} options.maxDelay - Maximum delay in ms (default: 10000)
|
|
15
|
+
* @param {Function} options.onRetry - Callback on each retry
|
|
16
|
+
* @returns {Promise<any>} Result of the function
|
|
17
|
+
*/
|
|
18
|
+
export const retryAsync = async (fn, options = {}) => {
|
|
19
|
+
const {
|
|
20
|
+
maxAttempts = 3,
|
|
21
|
+
initialDelay = 1000,
|
|
22
|
+
maxDelay = 10000,
|
|
23
|
+
onRetry = null,
|
|
24
|
+
} = options;
|
|
25
|
+
|
|
26
|
+
let lastError;
|
|
27
|
+
|
|
28
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
lastError = error;
|
|
33
|
+
|
|
34
|
+
if (attempt < maxAttempts) {
|
|
35
|
+
const delay = Math.min(initialDelay * 2 ** (attempt - 1), maxDelay);
|
|
36
|
+
if (onRetry) {
|
|
37
|
+
onRetry({ attempt, delay, error });
|
|
38
|
+
}
|
|
39
|
+
await sleep(delay);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw lastError;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sleeps for a specified number of milliseconds
|
|
49
|
+
* @param {number} ms - Milliseconds to sleep
|
|
50
|
+
* @returns {Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
export const sleep = (ms) => {
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validates required environment variables
|
|
58
|
+
* @param {Array<string>} vars - Variable names to validate
|
|
59
|
+
* @throws {Error} If any variables are missing
|
|
60
|
+
* @returns {boolean} True if all variables exist
|
|
61
|
+
*/
|
|
62
|
+
export const validateEnv = (vars) => {
|
|
63
|
+
const missing = vars.filter((v) => !process.env[v]);
|
|
64
|
+
|
|
65
|
+
if (missing.length > 0) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Missing required environment variables: ${missing.join(", ")}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deep merges two objects
|
|
76
|
+
* @param {Object} target - Target object
|
|
77
|
+
* @param {Object} source - Source object to merge
|
|
78
|
+
* @returns {Object} Merged object
|
|
79
|
+
*/
|
|
80
|
+
export const deepMerge = (target, source) => {
|
|
81
|
+
const output = Object.assign({}, target);
|
|
82
|
+
|
|
83
|
+
if (isObject(target) && isObject(source)) {
|
|
84
|
+
Object.keys(source).forEach((key) => {
|
|
85
|
+
// Guard against prototype pollution
|
|
86
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (isObject(source[key])) {
|
|
90
|
+
if (!(key in target)) {
|
|
91
|
+
Object.assign(output, { [key]: source[key] });
|
|
92
|
+
} else {
|
|
93
|
+
output[key] = deepMerge(target[key], source[key]);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
Object.assign(output, { [key]: source[key] });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return output;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Checks if value is a plain object
|
|
106
|
+
* @param {any} item - Item to check
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export const isObject = (item) => {
|
|
110
|
+
return item && typeof item === "object" && !Array.isArray(item);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @deprecated Use `getErrorMessage` from `@techstream/quark-core/errors` instead.
|
|
115
|
+
* Normalizes error messages for consistency
|
|
116
|
+
* @param {Error|string} error - Error to normalize
|
|
117
|
+
* @returns {string} Normalized error message
|
|
118
|
+
*/
|
|
119
|
+
export { getErrorMessage as normalizeErrorMessage } from "./errors.js";
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generates a cryptographically secure random string of specified length.
|
|
123
|
+
* Uses crypto.randomBytes for secure randomness — safe for tokens and IDs.
|
|
124
|
+
* @param {number} length - String length
|
|
125
|
+
* @param {string} chars - Characters to use (default: alphanumeric)
|
|
126
|
+
* @returns {string} Random string
|
|
127
|
+
*/
|
|
128
|
+
export const randomString = (
|
|
129
|
+
length = 16,
|
|
130
|
+
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
|
|
131
|
+
) => {
|
|
132
|
+
const bytes = crypto.randomBytes(length);
|
|
133
|
+
let result = "";
|
|
134
|
+
for (let i = 0; i < length; i++) {
|
|
135
|
+
result += chars.charAt(bytes[i] % chars.length);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sanitizes a string for use in URLs or IDs
|
|
142
|
+
* @param {string} str - String to sanitize
|
|
143
|
+
* @returns {string} Sanitized string
|
|
144
|
+
*/
|
|
145
|
+
export const sanitizeId = (str) => {
|
|
146
|
+
return str
|
|
147
|
+
.toLowerCase()
|
|
148
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
149
|
+
.replace(/^-+|-+$/g, "");
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Formats bytes to human-readable size
|
|
154
|
+
* @param {number} bytes - Number of bytes
|
|
155
|
+
* @returns {string} Formatted size
|
|
156
|
+
*/
|
|
157
|
+
export const formatBytes = (bytes) => {
|
|
158
|
+
if (bytes === 0) return "0 Bytes";
|
|
159
|
+
|
|
160
|
+
const k = 1024;
|
|
161
|
+
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
162
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
163
|
+
|
|
164
|
+
return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Measures execution time of async function
|
|
169
|
+
* @param {Function} fn - Async function to measure
|
|
170
|
+
* @returns {Promise<Object>} { result, duration: ms }
|
|
171
|
+
*/
|
|
172
|
+
export const measureTime = async (fn) => {
|
|
173
|
+
const start = performance.now();
|
|
174
|
+
const result = await fn();
|
|
175
|
+
const duration = performance.now() - start;
|
|
176
|
+
|
|
177
|
+
return { result, duration: Math.round(duration * 100) / 100 };
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Creates a debounced function
|
|
182
|
+
* @param {Function} fn - Function to debounce
|
|
183
|
+
* @param {number} delay - Delay in milliseconds
|
|
184
|
+
* @returns {Function} Debounced function
|
|
185
|
+
*/
|
|
186
|
+
export const debounce = (fn, delay = 300) => {
|
|
187
|
+
let timeoutId;
|
|
188
|
+
|
|
189
|
+
return (...args) => {
|
|
190
|
+
clearTimeout(timeoutId);
|
|
191
|
+
timeoutId = setTimeout(() => fn(...args), delay);
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Creates a memoized function (caches results)
|
|
197
|
+
* @param {Function} fn - Function to memoize
|
|
198
|
+
* @param {number} ttl - Time to live in milliseconds (0 = no expiry)
|
|
199
|
+
* @returns {Function} Memoized function
|
|
200
|
+
*/
|
|
201
|
+
export const memoize = (fn, ttl = 0) => {
|
|
202
|
+
const cache = new Map();
|
|
203
|
+
|
|
204
|
+
return (...args) => {
|
|
205
|
+
const key = JSON.stringify(args);
|
|
206
|
+
|
|
207
|
+
if (cache.has(key)) {
|
|
208
|
+
const cached = cache.get(key);
|
|
209
|
+
if (ttl === 0 || Date.now() - cached.timestamp < ttl) {
|
|
210
|
+
return cached.value;
|
|
211
|
+
}
|
|
212
|
+
cache.delete(key);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const value = fn(...args);
|
|
216
|
+
cache.set(key, { value, timestamp: Date.now() });
|
|
217
|
+
return value;
|
|
218
|
+
};
|
|
219
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
debounce,
|
|
5
|
+
deepMerge,
|
|
6
|
+
formatBytes,
|
|
7
|
+
isObject,
|
|
8
|
+
memoize,
|
|
9
|
+
normalizeErrorMessage,
|
|
10
|
+
randomString,
|
|
11
|
+
retryAsync,
|
|
12
|
+
sanitizeId,
|
|
13
|
+
sleep,
|
|
14
|
+
validateEnv,
|
|
15
|
+
} from "../src/utils.js";
|
|
16
|
+
|
|
17
|
+
test("Utils Module", async (t) => {
|
|
18
|
+
await t.test("retryAsync succeeds on first attempt", async () => {
|
|
19
|
+
let attempts = 0;
|
|
20
|
+
const result = await retryAsync(async () => {
|
|
21
|
+
attempts++;
|
|
22
|
+
return "success";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
assert(result === "success");
|
|
26
|
+
assert(attempts === 1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await t.test("retryAsync retries on failure", async () => {
|
|
30
|
+
let attempts = 0;
|
|
31
|
+
const result = await retryAsync(
|
|
32
|
+
async () => {
|
|
33
|
+
attempts++;
|
|
34
|
+
if (attempts < 3) throw new Error("Fail");
|
|
35
|
+
return "success";
|
|
36
|
+
},
|
|
37
|
+
{ maxAttempts: 5, initialDelay: 10 },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
assert(result === "success");
|
|
41
|
+
assert(attempts === 3);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await t.test("retryAsync throws after max attempts", async () => {
|
|
45
|
+
let attempts = 0;
|
|
46
|
+
await assert.rejects(async () => {
|
|
47
|
+
await retryAsync(
|
|
48
|
+
async () => {
|
|
49
|
+
attempts++;
|
|
50
|
+
throw new Error("Always fails");
|
|
51
|
+
},
|
|
52
|
+
{ maxAttempts: 3, initialDelay: 10 },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
assert(attempts === 3);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t.test("sleep delays execution", async () => {
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
await sleep(50);
|
|
62
|
+
const elapsed = Date.now() - start;
|
|
63
|
+
assert(elapsed >= 40); // Allow some variance
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await t.test("validateEnv checks required variables", () => {
|
|
67
|
+
process.env.TEST_VAR = "test";
|
|
68
|
+
assert(validateEnv(["TEST_VAR"]) === true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t.test("validateEnv throws on missing variables", () => {
|
|
72
|
+
assert.throws(() => {
|
|
73
|
+
validateEnv(["NONEXISTENT_VAR_12345"]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await t.test("deepMerge merges objects", () => {
|
|
78
|
+
const result = deepMerge({ a: 1, b: { c: 2 } }, { b: { d: 3 }, e: 4 });
|
|
79
|
+
|
|
80
|
+
assert.deepStrictEqual(result, {
|
|
81
|
+
a: 1,
|
|
82
|
+
b: { c: 2, d: 3 },
|
|
83
|
+
e: 4,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await t.test("deepMerge handles nested objects", () => {
|
|
88
|
+
const result = deepMerge({ a: { b: { c: 1 } } }, { a: { b: { d: 2 } } });
|
|
89
|
+
|
|
90
|
+
assert.deepStrictEqual(result, {
|
|
91
|
+
a: { b: { c: 1, d: 2 } },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t.test("isObject identifies objects", () => {
|
|
96
|
+
assert(isObject({}));
|
|
97
|
+
assert(isObject({ a: 1 }));
|
|
98
|
+
assert(!isObject([]));
|
|
99
|
+
assert(!isObject(null));
|
|
100
|
+
assert(!isObject("string"));
|
|
101
|
+
assert(!isObject(123));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t.test("normalizeErrorMessage handles various types", () => {
|
|
105
|
+
assert(normalizeErrorMessage("string") === "string");
|
|
106
|
+
assert(normalizeErrorMessage(new Error("error")) === "error");
|
|
107
|
+
assert(normalizeErrorMessage({ message: "msg" }) === "msg");
|
|
108
|
+
assert(normalizeErrorMessage(null).includes("unknown"));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await t.test("randomString generates random strings", () => {
|
|
112
|
+
const str1 = randomString(16);
|
|
113
|
+
const str2 = randomString(16);
|
|
114
|
+
|
|
115
|
+
assert(str1.length === 16);
|
|
116
|
+
assert(str2.length === 16);
|
|
117
|
+
// Very unlikely to be equal
|
|
118
|
+
assert(str1 !== str2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await t.test("randomString respects length parameter", () => {
|
|
122
|
+
assert(randomString(10).length === 10);
|
|
123
|
+
assert(randomString(50).length === 50);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await t.test("sanitizeId converts to valid IDs", () => {
|
|
127
|
+
assert(sanitizeId("Hello World") === "hello-world");
|
|
128
|
+
assert(sanitizeId("Test_123") === "test-123");
|
|
129
|
+
assert(sanitizeId("---hello---") === "hello");
|
|
130
|
+
assert(sanitizeId("UPPERCASE") === "uppercase");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await t.test("formatBytes formats file sizes", () => {
|
|
134
|
+
assert(formatBytes(0) === "0 Bytes");
|
|
135
|
+
assert(formatBytes(1024) === "1 KB");
|
|
136
|
+
assert(formatBytes(1024 * 1024) === "1 MB");
|
|
137
|
+
assert(formatBytes(1024 * 1024 * 1024) === "1 GB");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t.test("debounce delays function execution", (_t, done) => {
|
|
141
|
+
let callCount = 0;
|
|
142
|
+
const debounced = debounce(() => {
|
|
143
|
+
callCount++;
|
|
144
|
+
}, 30);
|
|
145
|
+
|
|
146
|
+
debounced();
|
|
147
|
+
debounced();
|
|
148
|
+
debounced();
|
|
149
|
+
|
|
150
|
+
assert(callCount === 0); // Not called yet
|
|
151
|
+
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
assert(callCount === 1); // Called once after delay
|
|
154
|
+
done();
|
|
155
|
+
}, 50);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await t.test("memoize caches function results", () => {
|
|
159
|
+
let callCount = 0;
|
|
160
|
+
const memoized = memoize((x) => {
|
|
161
|
+
callCount++;
|
|
162
|
+
return x * 2;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
assert(memoized(5) === 10);
|
|
166
|
+
assert(callCount === 1);
|
|
167
|
+
assert(memoized(5) === 10);
|
|
168
|
+
assert(callCount === 1); // Same result from cache
|
|
169
|
+
|
|
170
|
+
assert(memoized(10) === 20);
|
|
171
|
+
assert(callCount === 2); // Different input, new call
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await t.test("memoize respects TTL", (_t, done) => {
|
|
175
|
+
let callCount = 0;
|
|
176
|
+
const memoized = memoize(
|
|
177
|
+
(x) => {
|
|
178
|
+
callCount++;
|
|
179
|
+
return x * 2;
|
|
180
|
+
},
|
|
181
|
+
30, // 30ms TTL
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
assert(memoized(5) === 10);
|
|
185
|
+
assert(callCount === 1);
|
|
186
|
+
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
assert(memoized(5) === 10);
|
|
189
|
+
assert(callCount === 2); // Cache expired, new call
|
|
190
|
+
done();
|
|
191
|
+
}, 50);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ZodError } from "zod";
|
|
2
|
+
import { ValidationError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates request body against a Zod schema
|
|
6
|
+
* @param {Request} request - Next.js Request object
|
|
7
|
+
* @param {import("zod").ZodSchema} schema - Zod schema to validate against
|
|
8
|
+
* @returns {Promise<any>} - Validated data
|
|
9
|
+
*/
|
|
10
|
+
export async function validateBody(request, schema) {
|
|
11
|
+
let body;
|
|
12
|
+
try {
|
|
13
|
+
body = await request.json();
|
|
14
|
+
} catch (_err) {
|
|
15
|
+
throw new ValidationError("Invalid JSON body");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
return schema.parse(body);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error instanceof ZodError) {
|
|
22
|
+
throw new ValidationError("Validation failed", error.errors);
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/test-imports.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Test script - verify all core modules load
|
|
2
|
+
import("./src/auth/index.js").then(() => {
|
|
3
|
+
console.log("✅ Auth module loaded");
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
import("./src/errors.js").then(() => {
|
|
7
|
+
console.log("✅ Errors module loaded");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
import("./src/utils.js").then(() => {
|
|
11
|
+
console.log("✅ Utils module loaded");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
import("./src/queue/index.js").then(() => {
|
|
15
|
+
console.log("✅ Queue module loaded");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
console.log("\n✨ All core modules are properly configured!");
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}, 500);
|