voltjs-framework 1.0.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/LICENSE +21 -0
- package/README.md +1265 -0
- package/bin/volt.js +139 -0
- package/package.json +56 -0
- package/src/api/graphql.js +399 -0
- package/src/api/rest.js +204 -0
- package/src/api/websocket.js +285 -0
- package/src/cli/build.js +111 -0
- package/src/cli/create.js +371 -0
- package/src/cli/db.js +106 -0
- package/src/cli/dev.js +114 -0
- package/src/cli/generate.js +278 -0
- package/src/cli/lint.js +172 -0
- package/src/cli/routes.js +118 -0
- package/src/cli/start.js +42 -0
- package/src/cli/test.js +138 -0
- package/src/core/app.js +701 -0
- package/src/core/config.js +232 -0
- package/src/core/middleware.js +133 -0
- package/src/core/plugins.js +88 -0
- package/src/core/react-renderer.js +244 -0
- package/src/core/renderer.js +337 -0
- package/src/core/router.js +183 -0
- package/src/database/index.js +461 -0
- package/src/database/migration.js +192 -0
- package/src/database/model.js +285 -0
- package/src/database/query.js +394 -0
- package/src/database/seeder.js +89 -0
- package/src/index.js +156 -0
- package/src/security/auth.js +425 -0
- package/src/security/cors.js +80 -0
- package/src/security/csrf.js +125 -0
- package/src/security/encryption.js +110 -0
- package/src/security/helmet.js +103 -0
- package/src/security/index.js +75 -0
- package/src/security/rateLimit.js +119 -0
- package/src/security/sanitizer.js +113 -0
- package/src/security/xss.js +110 -0
- package/src/ui/component.js +224 -0
- package/src/ui/reactive.js +503 -0
- package/src/ui/template.js +448 -0
- package/src/utils/cache.js +216 -0
- package/src/utils/collection.js +772 -0
- package/src/utils/cron.js +213 -0
- package/src/utils/date.js +223 -0
- package/src/utils/events.js +181 -0
- package/src/utils/excel.js +482 -0
- package/src/utils/form.js +547 -0
- package/src/utils/hash.js +121 -0
- package/src/utils/http.js +461 -0
- package/src/utils/logger.js +186 -0
- package/src/utils/mail.js +347 -0
- package/src/utils/paginator.js +179 -0
- package/src/utils/pdf.js +417 -0
- package/src/utils/queue.js +199 -0
- package/src/utils/schema.js +985 -0
- package/src/utils/sms.js +243 -0
- package/src/utils/storage.js +348 -0
- package/src/utils/string.js +236 -0
- package/src/utils/validation.js +318 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS HttpClient
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency HTTP client for external API calls.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { HttpClient } = require('voltjs');
|
|
8
|
+
*
|
|
9
|
+
* const api = new HttpClient({ baseURL: 'https://api.example.com' });
|
|
10
|
+
* api.setHeader('Authorization', 'Bearer token');
|
|
11
|
+
*
|
|
12
|
+
* const users = await api.get('/users');
|
|
13
|
+
* const created = await api.post('/users', { name: 'John' });
|
|
14
|
+
*
|
|
15
|
+
* // Quick usage
|
|
16
|
+
* const data = await HttpClient.get('https://api.example.com/data');
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const https = require('https');
|
|
22
|
+
const http = require('http');
|
|
23
|
+
const { URL } = require('url');
|
|
24
|
+
const zlib = require('zlib');
|
|
25
|
+
|
|
26
|
+
class HttpClient {
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.baseURL = options.baseURL || '';
|
|
29
|
+
this.headers = {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
'Accept': 'application/json',
|
|
32
|
+
'User-Agent': 'VoltJS/1.0',
|
|
33
|
+
...options.headers,
|
|
34
|
+
};
|
|
35
|
+
this.timeout = options.timeout || 30000;
|
|
36
|
+
this.retries = options.retries || 0;
|
|
37
|
+
this.retryDelay = options.retryDelay || 1000;
|
|
38
|
+
|
|
39
|
+
this._interceptors = { request: [], response: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Set a default header */
|
|
43
|
+
setHeader(key, value) {
|
|
44
|
+
this.headers[key] = value;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Set Bearer token */
|
|
49
|
+
setBearerToken(token) {
|
|
50
|
+
this.headers.Authorization = `Bearer ${token}`;
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Set Basic auth */
|
|
55
|
+
setBasicAuth(username, password) {
|
|
56
|
+
const cred = Buffer.from(`${username}:${password}`).toString('base64');
|
|
57
|
+
this.headers.Authorization = `Basic ${cred}`;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Add request interceptor */
|
|
62
|
+
onRequest(fn) { this._interceptors.request.push(fn); return this; }
|
|
63
|
+
|
|
64
|
+
/** Add response interceptor */
|
|
65
|
+
onResponse(fn) { this._interceptors.response.push(fn); return this; }
|
|
66
|
+
|
|
67
|
+
// HTTP Methods
|
|
68
|
+
async get(url, options = {}) { return this.request('GET', url, null, options); }
|
|
69
|
+
async post(url, data, options = {}) { return this.request('POST', url, data, options); }
|
|
70
|
+
async put(url, data, options = {}) { return this.request('PUT', url, data, options); }
|
|
71
|
+
async patch(url, data, options = {}) { return this.request('PATCH', url, data, options); }
|
|
72
|
+
async delete(url, options = {}) { return this.request('DELETE', url, null, options); }
|
|
73
|
+
async head(url, options = {}) { return this.request('HEAD', url, null, options); }
|
|
74
|
+
|
|
75
|
+
/** Core request method */
|
|
76
|
+
async request(method, url, data = null, options = {}) {
|
|
77
|
+
const fullURL = url.startsWith('http') ? url : this.baseURL + url;
|
|
78
|
+
const parsed = new URL(fullURL);
|
|
79
|
+
|
|
80
|
+
// Query params
|
|
81
|
+
if (options.params) {
|
|
82
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
83
|
+
parsed.searchParams.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let reqConfig = {
|
|
88
|
+
method,
|
|
89
|
+
url: parsed.toString(),
|
|
90
|
+
headers: { ...this.headers, ...options.headers },
|
|
91
|
+
data,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Run request interceptors
|
|
95
|
+
for (const fn of this._interceptors.request) {
|
|
96
|
+
reqConfig = await fn(reqConfig) || reqConfig;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Serialize body
|
|
100
|
+
let body = null;
|
|
101
|
+
if (reqConfig.data !== null && reqConfig.data !== undefined) {
|
|
102
|
+
if (typeof reqConfig.data === 'string' || Buffer.isBuffer(reqConfig.data)) {
|
|
103
|
+
body = reqConfig.data;
|
|
104
|
+
} else if (reqConfig.data instanceof URLSearchParams) {
|
|
105
|
+
body = reqConfig.data.toString();
|
|
106
|
+
reqConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
107
|
+
} else {
|
|
108
|
+
body = JSON.stringify(reqConfig.data);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Retry logic
|
|
113
|
+
let lastError;
|
|
114
|
+
const maxAttempts = (options.retries ?? this.retries) + 1;
|
|
115
|
+
|
|
116
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
const response = await this._doRequest(parsed, method, reqConfig.headers, body, options);
|
|
119
|
+
|
|
120
|
+
// Run response interceptors
|
|
121
|
+
let result = response;
|
|
122
|
+
for (const fn of this._interceptors.response) {
|
|
123
|
+
result = await fn(result) || result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
lastError = err;
|
|
129
|
+
if (attempt < maxAttempts) {
|
|
130
|
+
await new Promise(r => setTimeout(r, this.retryDelay * attempt));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw lastError;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Download a file */
|
|
139
|
+
async download(url, filePath) {
|
|
140
|
+
const fs = require('fs');
|
|
141
|
+
const path = require('path');
|
|
142
|
+
const dir = path.dirname(filePath);
|
|
143
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const fullURL = url.startsWith('http') ? url : this.baseURL + url;
|
|
147
|
+
const parsed = new URL(fullURL);
|
|
148
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
149
|
+
|
|
150
|
+
transport.get(fullURL, { headers: this.headers }, (response) => {
|
|
151
|
+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
152
|
+
// Follow redirects
|
|
153
|
+
return this.download(response.headers.location, filePath).then(resolve).catch(reject);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const file = fs.createWriteStream(filePath);
|
|
157
|
+
response.pipe(file);
|
|
158
|
+
file.on('finish', () => {
|
|
159
|
+
file.close();
|
|
160
|
+
resolve({ path: filePath, size: fs.statSync(filePath).size });
|
|
161
|
+
});
|
|
162
|
+
}).on('error', reject);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ===== STATIC SHORTCUTS =====
|
|
167
|
+
|
|
168
|
+
static async get(url, options = {}) { return new HttpClient(options).get(url, options); }
|
|
169
|
+
static async post(url, data, options = {}) { return new HttpClient(options).post(url, data, options); }
|
|
170
|
+
static async put(url, data, options = {}) { return new HttpClient(options).put(url, data, options); }
|
|
171
|
+
static async patch(url, data, options = {}) { return new HttpClient(options).patch(url, data, options); }
|
|
172
|
+
static async delete(url, options = {}) { return new HttpClient(options).delete(url, options); }
|
|
173
|
+
|
|
174
|
+
// ===== SWR: STALE-WHILE-REVALIDATE =====
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* SWR-like data fetching with cache + background revalidation
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const fetcher = HttpClient.swr({ ttl: 30, revalidate: 60 });
|
|
181
|
+
* const data = await fetcher.get('/api/users'); // Cached + background refresh
|
|
182
|
+
* fetcher.invalidate('/api/users');
|
|
183
|
+
*/
|
|
184
|
+
static swr(options = {}) {
|
|
185
|
+
const client = new HttpClient(options);
|
|
186
|
+
const cache = new Map();
|
|
187
|
+
const ttl = (options.ttl || 60) * 1000; // stale after N seconds
|
|
188
|
+
const revalidateTTL = (options.revalidate || 300) * 1000; // refetch after N seconds
|
|
189
|
+
const dedupeInterval = (options.dedupe || 2) * 1000;
|
|
190
|
+
const inflight = new Map();
|
|
191
|
+
|
|
192
|
+
const getCacheKey = (method, url, params) =>
|
|
193
|
+
`${method}:${url}:${JSON.stringify(params || {})}`;
|
|
194
|
+
|
|
195
|
+
const fetcher = {
|
|
196
|
+
async get(url, params = {}) {
|
|
197
|
+
const key = getCacheKey('GET', url, params);
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const entry = cache.get(key);
|
|
200
|
+
|
|
201
|
+
// Return cached data if fresh
|
|
202
|
+
if (entry && (now - entry.timestamp) < ttl) {
|
|
203
|
+
return entry.data;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Return stale data + revalidate in background
|
|
207
|
+
if (entry && (now - entry.timestamp) < revalidateTTL) {
|
|
208
|
+
// Dedupe: don't fire multiple revalidations
|
|
209
|
+
if (!inflight.has(key)) {
|
|
210
|
+
const revalidate = client.get(url, { params }).then(res => {
|
|
211
|
+
cache.set(key, { data: res, timestamp: Date.now() });
|
|
212
|
+
return res;
|
|
213
|
+
}).finally(() => {
|
|
214
|
+
setTimeout(() => inflight.delete(key), dedupeInterval);
|
|
215
|
+
});
|
|
216
|
+
inflight.set(key, revalidate);
|
|
217
|
+
}
|
|
218
|
+
return entry.data; // Return stale immediately
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// No cache or expired: fetch fresh
|
|
222
|
+
if (inflight.has(key)) return inflight.get(key); // Dedupe
|
|
223
|
+
|
|
224
|
+
const promise = client.get(url, { params }).then(res => {
|
|
225
|
+
cache.set(key, { data: res, timestamp: Date.now() });
|
|
226
|
+
return res;
|
|
227
|
+
}).finally(() => {
|
|
228
|
+
setTimeout(() => inflight.delete(key), dedupeInterval);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
inflight.set(key, promise);
|
|
232
|
+
return promise;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/** Invalidate cache for a URL */
|
|
236
|
+
invalidate(url) {
|
|
237
|
+
for (const key of cache.keys()) {
|
|
238
|
+
if (key.includes(url)) cache.delete(key);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/** Invalidate all cache */
|
|
243
|
+
invalidateAll() { cache.clear(); },
|
|
244
|
+
|
|
245
|
+
/** Mutate cached data directly (optimistic update) */
|
|
246
|
+
mutate(url, data) {
|
|
247
|
+
for (const [key, entry] of cache.entries()) {
|
|
248
|
+
if (key.includes(url)) {
|
|
249
|
+
entry.data = typeof data === 'function' ? data(entry.data) : data;
|
|
250
|
+
entry.timestamp = Date.now();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** Prefetch data into cache */
|
|
256
|
+
async prefetch(url, params = {}) {
|
|
257
|
+
const key = getCacheKey('GET', url, params);
|
|
258
|
+
const res = await client.get(url, { params });
|
|
259
|
+
cache.set(key, { data: res, timestamp: Date.now() });
|
|
260
|
+
return res;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/** Get cache stats */
|
|
264
|
+
stats() {
|
|
265
|
+
return { size: cache.size, inflight: inflight.size };
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return fetcher;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ===== ABORT / CANCEL =====
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Create an abortable request
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* const { promise, abort } = HttpClient.abortable('https://api.example.com/data');
|
|
279
|
+
* setTimeout(() => abort(), 5000); // Cancel after 5s
|
|
280
|
+
* const data = await promise;
|
|
281
|
+
*/
|
|
282
|
+
static abortable(url, options = {}) {
|
|
283
|
+
let aborted = false;
|
|
284
|
+
let rejectFn;
|
|
285
|
+
|
|
286
|
+
const promise = new Promise((resolve, reject) => {
|
|
287
|
+
rejectFn = reject;
|
|
288
|
+
if (aborted) return reject(new Error('Request aborted'));
|
|
289
|
+
|
|
290
|
+
const client = new HttpClient(options);
|
|
291
|
+
client.get(url, { ...options, allowError: true }).then(resolve).catch(reject);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
promise,
|
|
296
|
+
abort() {
|
|
297
|
+
aborted = true;
|
|
298
|
+
if (rejectFn) rejectFn(new Error('Request aborted'));
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ===== POLLING =====
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Poll a URL at intervals
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* const poller = HttpClient.poll('https://api.example.com/status', {
|
|
310
|
+
* interval: 5000,
|
|
311
|
+
* onData: (data) => console.log(data),
|
|
312
|
+
* until: (data) => data.status === 'complete',
|
|
313
|
+
* });
|
|
314
|
+
*
|
|
315
|
+
* poller.stop(); // Stop polling
|
|
316
|
+
*/
|
|
317
|
+
static poll(url, options = {}) {
|
|
318
|
+
const {
|
|
319
|
+
interval = 5000,
|
|
320
|
+
onData,
|
|
321
|
+
onError,
|
|
322
|
+
until,
|
|
323
|
+
maxAttempts = Infinity,
|
|
324
|
+
immediate = true,
|
|
325
|
+
} = options;
|
|
326
|
+
|
|
327
|
+
const client = new HttpClient(options);
|
|
328
|
+
let timer = null;
|
|
329
|
+
let attempts = 0;
|
|
330
|
+
let stopped = false;
|
|
331
|
+
|
|
332
|
+
const tick = async () => {
|
|
333
|
+
if (stopped) return;
|
|
334
|
+
attempts++;
|
|
335
|
+
try {
|
|
336
|
+
const res = await client.get(url);
|
|
337
|
+
if (onData) onData(res.data || res, attempts);
|
|
338
|
+
if (until && until(res.data || res)) {
|
|
339
|
+
stopped = true;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (onError) onError(err, attempts);
|
|
344
|
+
}
|
|
345
|
+
if (!stopped && attempts < maxAttempts) {
|
|
346
|
+
timer = setTimeout(tick, interval);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
if (immediate) tick();
|
|
351
|
+
else timer = setTimeout(tick, interval);
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
stop() { stopped = true; if (timer) clearTimeout(timer); },
|
|
355
|
+
get attempts() { return attempts; },
|
|
356
|
+
get running() { return !stopped; },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ===== CONCURRENT REQUESTS =====
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Execute multiple requests in parallel
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* const [users, posts] = await HttpClient.all([
|
|
367
|
+
* HttpClient.get('/api/users'),
|
|
368
|
+
* HttpClient.get('/api/posts'),
|
|
369
|
+
* ]);
|
|
370
|
+
*/
|
|
371
|
+
static all(requests) {
|
|
372
|
+
return Promise.all(requests);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Like Promise.allSettled — returns all results even if some fail */
|
|
376
|
+
static allSettled(requests) {
|
|
377
|
+
return Promise.allSettled(requests);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Race: return first to resolve */
|
|
381
|
+
static race(requests) {
|
|
382
|
+
return Promise.race(requests);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ===== INTERNAL =====
|
|
386
|
+
|
|
387
|
+
_doRequest(parsed, method, headers, body, options) {
|
|
388
|
+
return new Promise((resolve, reject) => {
|
|
389
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
390
|
+
|
|
391
|
+
const reqOptions = {
|
|
392
|
+
hostname: parsed.hostname,
|
|
393
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
394
|
+
path: parsed.pathname + parsed.search,
|
|
395
|
+
method,
|
|
396
|
+
headers: { ...headers },
|
|
397
|
+
timeout: options.timeout || this.timeout,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
if (body) {
|
|
401
|
+
reqOptions.headers['Content-Length'] = Buffer.byteLength(body);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Accept gzip
|
|
405
|
+
reqOptions.headers['Accept-Encoding'] = 'gzip, deflate';
|
|
406
|
+
|
|
407
|
+
const req = transport.request(reqOptions, (res) => {
|
|
408
|
+
const chunks = [];
|
|
409
|
+
|
|
410
|
+
// Handle gzip
|
|
411
|
+
let stream = res;
|
|
412
|
+
const encoding = res.headers['content-encoding'];
|
|
413
|
+
if (encoding === 'gzip') {
|
|
414
|
+
stream = res.pipe(zlib.createGunzip());
|
|
415
|
+
} else if (encoding === 'deflate') {
|
|
416
|
+
stream = res.pipe(zlib.createInflate());
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
stream.on('data', chunk => chunks.push(chunk));
|
|
420
|
+
stream.on('end', () => {
|
|
421
|
+
const rawBody = Buffer.concat(chunks).toString('utf-8');
|
|
422
|
+
|
|
423
|
+
let parsedBody = rawBody;
|
|
424
|
+
const contentType = res.headers['content-type'] || '';
|
|
425
|
+
if (contentType.includes('application/json')) {
|
|
426
|
+
try { parsedBody = JSON.parse(rawBody); } catch { }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const response = {
|
|
430
|
+
status: res.statusCode,
|
|
431
|
+
statusText: res.statusMessage,
|
|
432
|
+
headers: res.headers,
|
|
433
|
+
data: parsedBody,
|
|
434
|
+
raw: rawBody,
|
|
435
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (!response.ok && !options.allowError) {
|
|
439
|
+
const error = new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`);
|
|
440
|
+
error.response = response;
|
|
441
|
+
reject(error);
|
|
442
|
+
} else {
|
|
443
|
+
resolve(response);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
stream.on('error', reject);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
req.on('error', reject);
|
|
450
|
+
req.on('timeout', () => {
|
|
451
|
+
req.destroy();
|
|
452
|
+
reject(new Error(`Request timed out after ${reqOptions.timeout}ms`));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (body) req.write(body);
|
|
456
|
+
req.end();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
module.exports = { HttpClient };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Logger
|
|
3
|
+
*
|
|
4
|
+
* Structured, colorful logging with levels, file output, and request logging.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { Logger } = require('voltjs');
|
|
8
|
+
*
|
|
9
|
+
* const log = new Logger({ level: 'debug', file: 'logs/app.log' });
|
|
10
|
+
* log.info('Server started', { port: 3000 });
|
|
11
|
+
* log.error('Failed', { error: err.message });
|
|
12
|
+
*
|
|
13
|
+
* // Request logging middleware
|
|
14
|
+
* app.use(Logger.requestLogger());
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const LEVELS = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, fatal: 5, silent: 6 };
|
|
23
|
+
|
|
24
|
+
const COLORS = {
|
|
25
|
+
trace: '\x1b[90m', // gray
|
|
26
|
+
debug: '\x1b[36m', // cyan
|
|
27
|
+
info: '\x1b[32m', // green
|
|
28
|
+
warn: '\x1b[33m', // yellow
|
|
29
|
+
error: '\x1b[31m', // red
|
|
30
|
+
fatal: '\x1b[35m', // magenta
|
|
31
|
+
reset: '\x1b[0m',
|
|
32
|
+
dim: '\x1b[2m',
|
|
33
|
+
bold: '\x1b[1m',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ICONS = { trace: '·', debug: '◆', info: '✓', warn: '⚠', error: '✗', fatal: '☠' };
|
|
37
|
+
|
|
38
|
+
class Logger {
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
this.level = options.level || process.env.LOG_LEVEL || 'info';
|
|
41
|
+
this.colorize = options.colorize !== false;
|
|
42
|
+
this.timestamp = options.timestamp !== false;
|
|
43
|
+
this.file = options.file || null;
|
|
44
|
+
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
|
|
45
|
+
this.prefix = options.prefix || '';
|
|
46
|
+
|
|
47
|
+
if (this.file) {
|
|
48
|
+
const dir = path.dirname(this.file);
|
|
49
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
trace(message, meta) { this._log('trace', message, meta); }
|
|
54
|
+
debug(message, meta) { this._log('debug', message, meta); }
|
|
55
|
+
info(message, meta) { this._log('info', message, meta); }
|
|
56
|
+
warn(message, meta) { this._log('warn', message, meta); }
|
|
57
|
+
error(message, meta) { this._log('error', message, meta); }
|
|
58
|
+
fatal(message, meta) { this._log('fatal', message, meta); }
|
|
59
|
+
|
|
60
|
+
/** Create a child logger with prefix */
|
|
61
|
+
child(prefix) {
|
|
62
|
+
return new Logger({
|
|
63
|
+
level: this.level,
|
|
64
|
+
colorize: this.colorize,
|
|
65
|
+
timestamp: this.timestamp,
|
|
66
|
+
file: this.file,
|
|
67
|
+
prefix: this.prefix ? `${this.prefix}:${prefix}` : prefix,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Time an operation */
|
|
72
|
+
time(label) {
|
|
73
|
+
const start = performance.now();
|
|
74
|
+
return {
|
|
75
|
+
end: (meta) => {
|
|
76
|
+
const duration = (performance.now() - start).toFixed(2);
|
|
77
|
+
this.debug(`${label} completed in ${duration}ms`, meta);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Create table from data */
|
|
83
|
+
table(data) {
|
|
84
|
+
if (!Array.isArray(data) || data.length === 0) return;
|
|
85
|
+
console.table(data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Request logging middleware */
|
|
89
|
+
static requestLogger(options = {}) {
|
|
90
|
+
const logger = new Logger(options);
|
|
91
|
+
|
|
92
|
+
return (req, res) => {
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
|
|
95
|
+
const originalEnd = res.end.bind(res);
|
|
96
|
+
res.end = (...args) => {
|
|
97
|
+
const duration = Date.now() - start;
|
|
98
|
+
const status = res.statusCode;
|
|
99
|
+
const color = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
|
|
100
|
+
|
|
101
|
+
logger._log(color, `${req.method} ${req.url} ${status} ${duration}ms`, {
|
|
102
|
+
method: req.method,
|
|
103
|
+
url: req.url,
|
|
104
|
+
status,
|
|
105
|
+
duration: `${duration}ms`,
|
|
106
|
+
ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress,
|
|
107
|
+
userAgent: req.headers['user-agent'],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
originalEnd(...args);
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Log to JSON format (for production/log aggregators) */
|
|
116
|
+
static json(options = {}) {
|
|
117
|
+
const logger = new Logger({ ...options, colorize: false });
|
|
118
|
+
logger._formatJSON = true;
|
|
119
|
+
return logger;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ===== INTERNAL =====
|
|
123
|
+
|
|
124
|
+
_log(level, message, meta) {
|
|
125
|
+
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
126
|
+
|
|
127
|
+
const ts = this.timestamp ? new Date().toISOString() : '';
|
|
128
|
+
const prefix = this.prefix ? `[${this.prefix}]` : '';
|
|
129
|
+
|
|
130
|
+
// JSON format
|
|
131
|
+
if (this._formatJSON) {
|
|
132
|
+
const entry = {
|
|
133
|
+
timestamp: ts,
|
|
134
|
+
level,
|
|
135
|
+
message,
|
|
136
|
+
...(this.prefix ? { context: this.prefix } : {}),
|
|
137
|
+
...(meta || {}),
|
|
138
|
+
};
|
|
139
|
+
const line = JSON.stringify(entry);
|
|
140
|
+
process.stdout.write(line + '\n');
|
|
141
|
+
if (this.file) this._writeFile(line);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Pretty format
|
|
146
|
+
const metaStr = meta ? ' ' + JSON.stringify(meta) : '';
|
|
147
|
+
|
|
148
|
+
if (this.colorize) {
|
|
149
|
+
const c = COLORS[level] || '';
|
|
150
|
+
const icon = ICONS[level] || '';
|
|
151
|
+
const tsStr = ts ? `${COLORS.dim}${ts}${COLORS.reset} ` : '';
|
|
152
|
+
const prefixStr = prefix ? `${COLORS.dim}${prefix}${COLORS.reset} ` : '';
|
|
153
|
+
const levelStr = `${c}${COLORS.bold}${level.toUpperCase().padEnd(5)}${COLORS.reset}`;
|
|
154
|
+
const metaColored = meta ? ` ${COLORS.dim}${JSON.stringify(meta)}${COLORS.reset}` : '';
|
|
155
|
+
|
|
156
|
+
process.stdout.write(`${tsStr}${c}${icon}${COLORS.reset} ${levelStr} ${prefixStr}${message}${metaColored}\n`);
|
|
157
|
+
} else {
|
|
158
|
+
const line = `${ts} ${level.toUpperCase().padEnd(5)} ${prefix} ${message}${metaStr}`.trim();
|
|
159
|
+
process.stdout.write(line + '\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// File output
|
|
163
|
+
if (this.file) {
|
|
164
|
+
const fileLine = `${ts} ${level.toUpperCase().padEnd(5)} ${prefix} ${message}${metaStr}`.trim();
|
|
165
|
+
this._writeFile(fileLine);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_writeFile(line) {
|
|
170
|
+
try {
|
|
171
|
+
// Rotate if too large
|
|
172
|
+
if (fs.existsSync(this.file)) {
|
|
173
|
+
const stat = fs.statSync(this.file);
|
|
174
|
+
if (stat.size > this.maxFileSize) {
|
|
175
|
+
const rotated = this.file + '.' + Date.now();
|
|
176
|
+
fs.renameSync(this.file, rotated);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
fs.appendFileSync(this.file, line + '\n');
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// Silently fail file logging
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { Logger };
|